@fgv/ts-extras-ollama 5.1.0-34

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.
Files changed (65) hide show
  1. package/.rush/temp/b70e5f6b6ada97ea70c5583027ea2b58b27bef46.tar.log +54 -0
  2. package/.rush/temp/chunked-rush-logs/ts-extras-ollama.build.chunks.jsonl +9 -0
  3. package/.rush/temp/operation/build/all.log +9 -0
  4. package/.rush/temp/operation/build/log-chunks.jsonl +9 -0
  5. package/.rush/temp/operation/build/state.json +3 -0
  6. package/.rush/temp/shrinkwrap-deps.json +681 -0
  7. package/CHANGELOG.json +4 -0
  8. package/README.md +105 -0
  9. package/config/api-extractor.json +38 -0
  10. package/config/jest.config.json +13 -0
  11. package/config/rig.json +6 -0
  12. package/dist/index.js +388 -0
  13. package/dist/index.js.map +1 -0
  14. package/dist/test/unit/chatStructured.test.js +287 -0
  15. package/dist/test/unit/chatStructured.test.js.map +1 -0
  16. package/dist/test/unit/fixtures/wireFixtures.js +90 -0
  17. package/dist/test/unit/fixtures/wireFixtures.js.map +1 -0
  18. package/dist/test/unit/modelManagement.test.js +118 -0
  19. package/dist/test/unit/modelManagement.test.js.map +1 -0
  20. package/dist/test/unit/ollamaClient.test.js +38 -0
  21. package/dist/test/unit/ollamaClient.test.js.map +1 -0
  22. package/dist/test/unit/pullModel.test.js +202 -0
  23. package/dist/test/unit/pullModel.test.js.map +1 -0
  24. package/dist/ts-extras-ollama.d.ts +365 -0
  25. package/dist/tsdoc-metadata.json +11 -0
  26. package/eslint.config.js +15 -0
  27. package/etc/ts-extras-ollama.api.md +139 -0
  28. package/lib/index.d.ts +341 -0
  29. package/lib/index.d.ts.map +1 -0
  30. package/lib/index.js +397 -0
  31. package/lib/index.js.map +1 -0
  32. package/lib/test/unit/chatStructured.test.d.ts +2 -0
  33. package/lib/test/unit/chatStructured.test.d.ts.map +1 -0
  34. package/lib/test/unit/chatStructured.test.js +289 -0
  35. package/lib/test/unit/chatStructured.test.js.map +1 -0
  36. package/lib/test/unit/fixtures/wireFixtures.d.ts +19 -0
  37. package/lib/test/unit/fixtures/wireFixtures.d.ts.map +1 -0
  38. package/lib/test/unit/fixtures/wireFixtures.js +93 -0
  39. package/lib/test/unit/fixtures/wireFixtures.js.map +1 -0
  40. package/lib/test/unit/modelManagement.test.d.ts +2 -0
  41. package/lib/test/unit/modelManagement.test.d.ts.map +1 -0
  42. package/lib/test/unit/modelManagement.test.js +120 -0
  43. package/lib/test/unit/modelManagement.test.js.map +1 -0
  44. package/lib/test/unit/ollamaClient.test.d.ts +2 -0
  45. package/lib/test/unit/ollamaClient.test.d.ts.map +1 -0
  46. package/lib/test/unit/ollamaClient.test.js +40 -0
  47. package/lib/test/unit/ollamaClient.test.js.map +1 -0
  48. package/lib/test/unit/pullModel.test.d.ts +2 -0
  49. package/lib/test/unit/pullModel.test.d.ts.map +1 -0
  50. package/lib/test/unit/pullModel.test.js +204 -0
  51. package/lib/test/unit/pullModel.test.js.map +1 -0
  52. package/package.json +83 -0
  53. package/rush-logs/ts-extras-ollama.build.cache.log +3 -0
  54. package/rush-logs/ts-extras-ollama.build.log +9 -0
  55. package/src/index.ts +655 -0
  56. package/src/test/unit/chatStructured.test.ts +330 -0
  57. package/src/test/unit/fixtures/wireFixtures.ts +95 -0
  58. package/src/test/unit/modelManagement.test.ts +128 -0
  59. package/src/test/unit/ollamaClient.test.ts +44 -0
  60. package/src/test/unit/pullModel.test.ts +220 -0
  61. package/temp/build/lint/_eslint-5eVG3S6w.json +30 -0
  62. package/temp/build/typescript/ts_8nwakTlr.json +1 -0
  63. package/temp/ts-extras-ollama.api.json +2528 -0
  64. package/temp/ts-extras-ollama.api.md +139 -0
  65. package/tsconfig.json +8 -0
@@ -0,0 +1,330 @@
1
+ import '@fgv/ts-utils-jest';
2
+ import { succeed } from '@fgv/ts-utils';
3
+ import { JsonSchema, type JsonObject } from '@fgv/ts-json-base';
4
+ import { type IOllamaChatMessage, type IOllamaClient, chatStructured, createOllamaClient } from '../../index';
5
+
6
+ const personSchema = JsonSchema.object({
7
+ name: JsonSchema.string(),
8
+ age: JsonSchema.integer()
9
+ });
10
+ type Person = JsonSchema.Static<typeof personSchema>;
11
+
12
+ /** A streaming `/api/chat` chunk in the upstream `ChatResponse` shape (the fields we consume). */
13
+ interface IChatChunk {
14
+ model: string;
15
+ message: { role: string; content: string };
16
+ done: boolean;
17
+ done_reason?: string;
18
+ }
19
+
20
+ /**
21
+ * Builds a structural mock of the upstream `AbortableAsyncIterator<ChatResponse>` returned by
22
+ * `client.chat({ stream: true })`. `abort()` flips a flag the generator checks before each chunk.
23
+ */
24
+ function makeChatIterator(
25
+ chunks: ReadonlyArray<IChatChunk>
26
+ ): { abort: jest.Mock } & AsyncIterable<IChatChunk> {
27
+ let aborted = false;
28
+ return {
29
+ abort: jest.fn(() => {
30
+ aborted = true;
31
+ }),
32
+ async *[Symbol.asyncIterator](): AsyncGenerator<IChatChunk> {
33
+ for (const chunk of chunks) {
34
+ if (aborted) {
35
+ throw new Error('The operation was aborted');
36
+ }
37
+ yield chunk;
38
+ }
39
+ }
40
+ };
41
+ }
42
+
43
+ /**
44
+ * Like {@link makeChatIterator}, but yields control (a real timer) after each chunk so a signal
45
+ * aborted *after* the listener is attached lands mid-stream — exercising the abort-listener path
46
+ * (vs. the already-aborted branch).
47
+ */
48
+ function makeDelayedChatIterator(
49
+ chunks: ReadonlyArray<IChatChunk>
50
+ ): { abort: jest.Mock } & AsyncIterable<IChatChunk> {
51
+ let aborted = false;
52
+ return {
53
+ abort: jest.fn(() => {
54
+ aborted = true;
55
+ }),
56
+ async *[Symbol.asyncIterator](): AsyncGenerator<IChatChunk> {
57
+ for (const chunk of chunks) {
58
+ if (aborted) {
59
+ throw new Error('The operation was aborted');
60
+ }
61
+ yield chunk;
62
+ await new Promise<void>((resolve) => setTimeout(resolve, 30));
63
+ }
64
+ }
65
+ };
66
+ }
67
+
68
+ function mockChatClient(chat: unknown): IOllamaClient {
69
+ return { chat } as unknown as IOllamaClient;
70
+ }
71
+
72
+ /** Chunks whose assembled `message.content` is `{"name":"ada","age":42}`. */
73
+ function validPersonChunks(model: string = 'llama3.1:8b'): IChatChunk[] {
74
+ return [
75
+ { model, message: { role: 'assistant', content: '{"name":"ada",' }, done: false },
76
+ { model, message: { role: 'assistant', content: '"age":42}' }, done: false },
77
+ { model, message: { role: 'assistant', content: '' }, done: true, done_reason: 'stop' }
78
+ ];
79
+ }
80
+
81
+ const userTurn: ReadonlyArray<IOllamaChatMessage> = [{ role: 'user', content: 'Describe Ada.' }];
82
+
83
+ describe('chatStructured (layer 1 — structural client mock)', () => {
84
+ test('validates the assembled document against the same schema sent on the wire', async () => {
85
+ const chat = jest.fn().mockResolvedValue(makeChatIterator(validPersonChunks()));
86
+ const client = mockChatClient(chat);
87
+
88
+ expect(
89
+ await chatStructured(client, { model: 'llama3.1:8b', messages: userTurn, schema: personSchema })
90
+ ).toSucceedAndSatisfy((result) => {
91
+ const value: Person = result.value;
92
+ expect(value).toEqual({ name: 'ada', age: 42 });
93
+ expect(result.raw).toBe('{"name":"ada","age":42}');
94
+ expect(result.model).toBe('llama3.1:8b');
95
+ expect(result.doneReason).toBe('stop');
96
+ });
97
+
98
+ // The wire request: streaming, messages mapped, and the sanitized schema as `format`.
99
+ const request = chat.mock.calls[0][0] as JsonObject;
100
+ expect(request.stream).toBe(true);
101
+ expect(request.messages).toEqual([{ role: 'user', content: 'Describe Ada.' }]);
102
+ const format = request.format as JsonObject;
103
+ expect(format.type).toBe('object');
104
+ expect(format.properties).toEqual({ name: { type: 'string' }, age: { type: 'integer' } });
105
+ expect(format.required).toEqual(['name', 'age']);
106
+ // Draft-07-only keywords are stripped before hitting Ollama's `format`.
107
+ expect(format).not.toHaveProperty('additionalProperties');
108
+ expect(format).not.toHaveProperty('$schema');
109
+ // No options / keep_alive were supplied.
110
+ expect(request).not.toHaveProperty('options');
111
+ expect(request).not.toHaveProperty('keep_alive');
112
+ });
113
+
114
+ test('forwards options, keep_alive, and base64 images verbatim', async () => {
115
+ const chat = jest.fn().mockResolvedValue(makeChatIterator(validPersonChunks()));
116
+ const client = mockChatClient(chat);
117
+ const messages: ReadonlyArray<IOllamaChatMessage> = [
118
+ { role: 'system', content: 'Be terse.' },
119
+ { role: 'user', content: 'Look', images: ['aGVsbG8='] }
120
+ ];
121
+
122
+ expect(
123
+ await chatStructured(client, {
124
+ model: 'llama3.1:8b',
125
+ messages,
126
+ schema: personSchema,
127
+ options: { temperature: 0, seed: 7 },
128
+ keepAlive: '5m'
129
+ })
130
+ ).toSucceed();
131
+
132
+ const request = chat.mock.calls[0][0] as JsonObject;
133
+ expect(request.options).toEqual({ temperature: 0, seed: 7 });
134
+ expect(request.keep_alive).toBe('5m');
135
+ expect(request.messages).toEqual([
136
+ { role: 'system', content: 'Be terse.' },
137
+ { role: 'user', content: 'Look', images: ['aGVsbG8='] }
138
+ ]);
139
+ });
140
+
141
+ test('fails with context when the response parses but violates the schema', async () => {
142
+ const chunks: IChatChunk[] = [
143
+ {
144
+ model: 'llama3.1:8b',
145
+ message: { role: 'assistant', content: '{"name":"ada","age":"old"}' },
146
+ done: true
147
+ }
148
+ ];
149
+ const client = mockChatClient(jest.fn().mockResolvedValue(makeChatIterator(chunks)));
150
+
151
+ expect(
152
+ await chatStructured(client, { model: 'llama3.1:8b', messages: userTurn, schema: personSchema })
153
+ ).toFailWith(/chatStructured\(llama3\.1:8b\): response failed schema validation/i);
154
+ });
155
+
156
+ test('fails with context when the response is not valid JSON', async () => {
157
+ const chunks: IChatChunk[] = [
158
+ { model: 'llama3.1:8b', message: { role: 'assistant', content: 'not json at all' }, done: true }
159
+ ];
160
+ const client = mockChatClient(jest.fn().mockResolvedValue(makeChatIterator(chunks)));
161
+
162
+ expect(
163
+ await chatStructured(client, { model: 'llama3.1:8b', messages: userTurn, schema: personSchema })
164
+ ).toFailWith(/chatStructured\(llama3\.1:8b\): response was not valid JSON/i);
165
+ });
166
+
167
+ test('an already-aborted signal cancels the generation', async () => {
168
+ const iterator = makeChatIterator(validPersonChunks());
169
+ const client = mockChatClient(jest.fn().mockResolvedValue(iterator));
170
+ const controller = new AbortController();
171
+ controller.abort();
172
+
173
+ expect(
174
+ await chatStructured(client, {
175
+ model: 'llama3.1:8b',
176
+ messages: userTurn,
177
+ schema: personSchema,
178
+ signal: controller.signal
179
+ })
180
+ ).toFailWith(/abort/i);
181
+ expect(iterator.abort).toHaveBeenCalledTimes(1);
182
+ });
183
+
184
+ test('a mid-stream abort cancels the generation and removes the listener', async () => {
185
+ const iterator = makeDelayedChatIterator(validPersonChunks());
186
+ const client = mockChatClient(jest.fn().mockResolvedValue(iterator));
187
+ const controller = new AbortController();
188
+ const removeSpy = jest.spyOn(controller.signal, 'removeEventListener');
189
+
190
+ const promise = chatStructured(client, {
191
+ model: 'llama3.1:8b',
192
+ messages: userTurn,
193
+ schema: personSchema,
194
+ signal: controller.signal
195
+ });
196
+ // Let the request settle and the listener attach + first chunk land, then abort mid-stream so
197
+ // the abort fires through the listener (not the already-aborted branch).
198
+ await new Promise<void>((resolve) => setTimeout(resolve, 5));
199
+ controller.abort();
200
+
201
+ expect(await promise).toFailWith(/abort/i);
202
+ expect(iterator.abort).toHaveBeenCalled();
203
+ expect(removeSpy).toHaveBeenCalledWith('abort', expect.any(Function));
204
+ });
205
+
206
+ test('removes the abort listener after a successful completion', async () => {
207
+ const client = mockChatClient(jest.fn().mockResolvedValue(makeChatIterator(validPersonChunks())));
208
+ const controller = new AbortController();
209
+ const removeSpy = jest.spyOn(controller.signal, 'removeEventListener');
210
+
211
+ expect(
212
+ await chatStructured(client, {
213
+ model: 'llama3.1:8b',
214
+ messages: userTurn,
215
+ schema: personSchema,
216
+ signal: controller.signal
217
+ })
218
+ ).toSucceed();
219
+ expect(removeSpy).toHaveBeenCalledWith('abort', expect.any(Function));
220
+ });
221
+ });
222
+
223
+ describe('chatStructured — draft-07 format sanitizer', () => {
224
+ test('recursively strips $schema and additionalProperties while preserving property names and arrays', async () => {
225
+ // A schema double whose toJson() carries $schema, nested additionalProperties, an array, and a
226
+ // property *named* additionalProperties (which must survive — it is a name, not a keyword).
227
+ const rawSchema: JsonObject = {
228
+ $schema: 'http://json-schema.org/draft-07/schema#',
229
+ type: 'object',
230
+ properties: {
231
+ nested: {
232
+ type: 'object',
233
+ properties: { x: { type: 'string' } },
234
+ additionalProperties: false
235
+ },
236
+ additionalProperties: { type: 'string' }
237
+ },
238
+ required: ['nested'],
239
+ additionalProperties: false
240
+ };
241
+ const schemaDouble = {
242
+ _type: 'object',
243
+ toJson: () => rawSchema,
244
+ validate: (value: unknown) => succeed(value)
245
+ } as unknown as JsonSchema.ISchemaValidator<unknown>;
246
+
247
+ const chat = jest
248
+ .fn()
249
+ .mockResolvedValue(
250
+ makeChatIterator([
251
+ { model: 'm', message: { role: 'assistant', content: '{"nested":{"x":"y"}}' }, done: true }
252
+ ])
253
+ );
254
+ const client = mockChatClient(chat);
255
+
256
+ expect(
257
+ await chatStructured(client, { model: 'm', messages: userTurn, schema: schemaDouble })
258
+ ).toSucceed();
259
+
260
+ const format = (chat.mock.calls[0][0] as JsonObject).format as JsonObject;
261
+ expect(format).not.toHaveProperty('$schema');
262
+ expect(format).not.toHaveProperty('additionalProperties');
263
+ expect(format.required).toEqual(['nested']);
264
+ const properties = format.properties as JsonObject;
265
+ // The nested object's additionalProperties is stripped too.
266
+ expect(properties.nested).toEqual({ type: 'object', properties: { x: { type: 'string' } } });
267
+ // A property literally named `additionalProperties` is preserved (name, not keyword).
268
+ expect(properties.additionalProperties).toEqual({ type: 'string' });
269
+ });
270
+ });
271
+
272
+ /**
273
+ * Layer 2 — a real client over a mock `fetch` that streams a newline-delimited `/api/chat`
274
+ * response. Proves the no-drift claim end-to-end: the request body that actually goes on the wire
275
+ * carries `format` = the sanitized `schema.toJson()`, and the assembled reply validates.
276
+ */
277
+ function ndjsonResponse(lines: ReadonlyArray<string>): Response {
278
+ const bytes = new TextEncoder().encode(lines.join('\n') + '\n');
279
+ const mid = Math.floor(bytes.length / 2);
280
+ const parts = [bytes.slice(0, mid), bytes.slice(mid)];
281
+ let i = 0;
282
+ const stream = new ReadableStream<Uint8Array>({
283
+ pull(controller: ReadableStreamDefaultController<Uint8Array>): void {
284
+ if (i < parts.length) {
285
+ controller.enqueue(parts[i++]);
286
+ } else {
287
+ controller.close();
288
+ }
289
+ }
290
+ });
291
+ return new Response(stream, { status: 200 });
292
+ }
293
+
294
+ describe('chatStructured (layer 2 — fetch-level wire-format integration)', () => {
295
+ test('sends the sanitized schema as the wire format and validates the assembled reply', async () => {
296
+ const lines = [
297
+ JSON.stringify({
298
+ model: 'llama3.1:8b',
299
+ message: { role: 'assistant', content: '{"name":"ada",' },
300
+ done: false
301
+ }),
302
+ JSON.stringify({
303
+ model: 'llama3.1:8b',
304
+ message: { role: 'assistant', content: '"age":42}' },
305
+ done: true,
306
+ done_reason: 'stop'
307
+ })
308
+ ];
309
+ const fetchMock = jest.fn().mockResolvedValue(ndjsonResponse(lines));
310
+ const client = createOllamaClient({ fetch: fetchMock as unknown as typeof fetch }).orThrow();
311
+
312
+ expect(
313
+ await chatStructured(client, { model: 'llama3.1:8b', messages: userTurn, schema: personSchema })
314
+ ).toSucceedAndSatisfy((result) => {
315
+ expect(result.value).toEqual({ name: 'ada', age: 42 });
316
+ expect(result.raw).toBe('{"name":"ada","age":42}');
317
+ expect(result.doneReason).toBe('stop');
318
+ });
319
+
320
+ // The actual wire body carries the sanitized schema as `format` — no-drift proven end-to-end.
321
+ const requestInit = fetchMock.mock.calls[0][1] as { body: string };
322
+ const body = JSON.parse(requestInit.body) as JsonObject;
323
+ expect(body.stream).toBe(true);
324
+ const format = body.format as JsonObject;
325
+ expect(format.type).toBe('object');
326
+ expect(format.properties).toEqual({ name: { type: 'string' }, age: { type: 'integer' } });
327
+ expect(format).not.toHaveProperty('additionalProperties');
328
+ expect(format).not.toHaveProperty('$schema');
329
+ });
330
+ });
@@ -0,0 +1,95 @@
1
+ import { type ListResponse, type ShowResponse } from 'ollama';
2
+
3
+ /**
4
+ * Canned wire-shape fixtures for the layer-1 structural-client-mock tests.
5
+ *
6
+ * These mirror the actual JSON the Ollama daemon returns — snake_case keys and **string**
7
+ * timestamps. The upstream `ollama` types annotate `modified_at` / `expires_at` as `Date`, but the
8
+ * library returns `response.json()` verbatim so the wire delivers RFC3339 strings; each fixture is
9
+ * cast once (`as unknown as <Response>`) to reconcile the inaccurate upstream annotation with the
10
+ * real shape, so the normalization layer is exercised against the data it actually sees.
11
+ */
12
+
13
+ /** `GET /api/tags` — one fully-detailed model plus one with sparse `details` (missing fields). */
14
+ export const tagsResponse: ListResponse = {
15
+ models: [
16
+ {
17
+ name: 'llama3.1:8b',
18
+ model: 'llama3.1:8b',
19
+ modified_at: '2024-08-01T12:34:56.789Z',
20
+ size: 4661226402,
21
+ digest: 'sha256:abc123',
22
+ details: {
23
+ parent_model: '',
24
+ format: 'gguf',
25
+ family: 'llama',
26
+ families: ['llama'],
27
+ parameter_size: '8.0B',
28
+ quantization_level: 'Q4_0'
29
+ }
30
+ },
31
+ {
32
+ name: 'custom:latest',
33
+ model: 'custom:latest',
34
+ modified_at: '2024-09-15T08:00:00.000Z',
35
+ size: 1234567,
36
+ digest: 'sha256:def456',
37
+ // Sparse details — Ollama omits fields it cannot determine; exercises the optional-field path.
38
+ details: {
39
+ format: 'gguf',
40
+ family: 'llama'
41
+ }
42
+ }
43
+ ]
44
+ } as unknown as ListResponse;
45
+
46
+ /** `GET /api/ps` — a running model with `expires_at` + `size_vram` (no `modified_at`). */
47
+ export const psResponse: ListResponse = {
48
+ models: [
49
+ {
50
+ name: 'llama3.1:8b',
51
+ model: 'llama3.1:8b',
52
+ size: 4661226402,
53
+ digest: 'sha256:abc123',
54
+ details: {
55
+ parent_model: '',
56
+ format: 'gguf',
57
+ family: 'llama',
58
+ families: ['llama'],
59
+ parameter_size: '8.0B',
60
+ quantization_level: 'Q4_0'
61
+ },
62
+ expires_at: '2024-08-01T13:00:00.000Z',
63
+ size_vram: 4661226402
64
+ }
65
+ ]
66
+ } as unknown as ListResponse;
67
+
68
+ /** `POST /api/show` — full response including the low-level `model_info` metadata bag. */
69
+ export const showResponse: ShowResponse = {
70
+ modelfile: 'FROM llama3.1:8b',
71
+ parameters: 'stop "<|eot_id|>"',
72
+ template: '{{ .Prompt }}',
73
+ details: {
74
+ parent_model: '',
75
+ format: 'gguf',
76
+ family: 'llama',
77
+ families: ['llama'],
78
+ parameter_size: '8.0B',
79
+ quantization_level: 'Q4_0'
80
+ },
81
+ model_info: {
82
+ 'general.architecture': 'llama',
83
+ 'llama.context_length': 131072,
84
+ 'llama.embedding_length': 4096
85
+ },
86
+ capabilities: ['completion', 'tools']
87
+ } as unknown as ShowResponse;
88
+
89
+ /** `POST /api/show` — a minimal response with `model_info` and `capabilities` omitted. */
90
+ export const showResponseMinimal: ShowResponse = {
91
+ details: {
92
+ format: 'gguf',
93
+ family: 'llama'
94
+ }
95
+ } as unknown as ShowResponse;
@@ -0,0 +1,128 @@
1
+ import '@fgv/ts-utils-jest';
2
+ import { type IOllamaClient, listModels, listRunning, showModel, deleteModel } from '../../index';
3
+ import { psResponse, showResponse, showResponseMinimal, tagsResponse } from './fixtures/wireFixtures';
4
+
5
+ /**
6
+ * Builds a structural client mock exposing only the methods under test, typed back to
7
+ * {@link IOllamaClient}. This is the layer-1 seam from the design (§7) — it verifies the wire→fgv
8
+ * mapping and Result success/failure paths without HTTP or the `ollama` lib internals.
9
+ */
10
+ function mockClient(methods: Partial<Record<'list' | 'ps' | 'show' | 'delete', unknown>>): IOllamaClient {
11
+ return methods as unknown as IOllamaClient;
12
+ }
13
+
14
+ describe('listModels', () => {
15
+ test('normalizes /api/tags entries to camelCase with a parsed modifiedAt Date', async () => {
16
+ const client = mockClient({ list: jest.fn().mockResolvedValue(tagsResponse) });
17
+ expect(await listModels(client)).toSucceedAndSatisfy((models) => {
18
+ expect(models).toHaveLength(2);
19
+ const [full, sparse] = models;
20
+ expect(full.name).toBe('llama3.1:8b');
21
+ expect(full.size).toBe(4661226402);
22
+ expect(full.digest).toBe('sha256:abc123');
23
+ expect(full.modifiedAt).toBeInstanceOf(Date);
24
+ expect(full.modifiedAt.toISOString()).toBe('2024-08-01T12:34:56.789Z');
25
+ expect(full.details).toEqual({
26
+ parentModel: '',
27
+ format: 'gguf',
28
+ family: 'llama',
29
+ families: ['llama'],
30
+ parameterSize: '8.0B',
31
+ quantizationLevel: 'Q4_0'
32
+ });
33
+ // An empty `parent_model` is preserved as '' (present-and-empty, distinct from undefined).
34
+ expect(full.details.parentModel).toBe('');
35
+ // Sparse details: omitted wire fields normalize to undefined.
36
+ expect(sparse.details.format).toBe('gguf');
37
+ expect(sparse.details.parameterSize).toBeUndefined();
38
+ expect(sparse.details.quantizationLevel).toBeUndefined();
39
+ expect(sparse.details.parentModel).toBeUndefined();
40
+ expect(sparse.details.families).toBeUndefined();
41
+ });
42
+ });
43
+
44
+ test('fails when the client throws', async () => {
45
+ const client = mockClient({ list: jest.fn().mockRejectedValue(new Error('connection refused')) });
46
+ expect(await listModels(client)).toFailWith(/connection refused/);
47
+ });
48
+ });
49
+
50
+ describe('listRunning', () => {
51
+ test('normalizes /api/ps entries with expiresAt Date and sizeVram', async () => {
52
+ const client = mockClient({ ps: jest.fn().mockResolvedValue(psResponse) });
53
+ expect(await listRunning(client)).toSucceedAndSatisfy((running) => {
54
+ expect(running).toHaveLength(1);
55
+ const [model] = running;
56
+ expect(model.name).toBe('llama3.1:8b');
57
+ expect(model.sizeVram).toBe(4661226402);
58
+ expect(model.expiresAt).toBeInstanceOf(Date);
59
+ expect(model.expiresAt.toISOString()).toBe('2024-08-01T13:00:00.000Z');
60
+ expect(model.details.family).toBe('llama');
61
+ });
62
+ });
63
+
64
+ test('fails when the client throws', async () => {
65
+ const client = mockClient({ ps: jest.fn().mockRejectedValue(new Error('daemon down')) });
66
+ expect(await listRunning(client)).toFailWith(/daemon down/);
67
+ });
68
+ });
69
+
70
+ describe('showModel', () => {
71
+ test('normalizes /api/show including the model_info metadata bag', async () => {
72
+ const show = jest.fn().mockResolvedValue(showResponse);
73
+ const client = mockClient({ show });
74
+ expect(await showModel(client, 'llama3.1:8b')).toSucceedAndSatisfy((info) => {
75
+ expect(info.modelfile).toBe('FROM llama3.1:8b');
76
+ expect(info.parameters).toBe('stop "<|eot_id|>"');
77
+ expect(info.template).toBe('{{ .Prompt }}');
78
+ expect(info.capabilities).toEqual(['completion', 'tools']);
79
+ expect(info.modelInfo).toEqual({
80
+ 'general.architecture': 'llama',
81
+ 'llama.context_length': 131072,
82
+ 'llama.embedding_length': 4096
83
+ });
84
+ expect(info.details.parameterSize).toBe('8.0B');
85
+ });
86
+ // No verbose flag was requested, so only `model` is on the request body.
87
+ expect(show).toHaveBeenCalledWith({ model: 'llama3.1:8b' });
88
+ });
89
+
90
+ test('maps an absent model_info to undefined', async () => {
91
+ const client = mockClient({ show: jest.fn().mockResolvedValue(showResponseMinimal) });
92
+ expect(await showModel(client, 'custom:latest')).toSucceedAndSatisfy((info) => {
93
+ expect(info.modelInfo).toBeUndefined();
94
+ expect(info.capabilities).toBeUndefined();
95
+ expect(info.modelfile).toBeUndefined();
96
+ expect(info.details.family).toBe('llama');
97
+ });
98
+ });
99
+
100
+ test('forwards verbose: true on the /api/show request body', async () => {
101
+ const show = jest.fn().mockResolvedValue(showResponse);
102
+ const client = mockClient({ show });
103
+ expect(await showModel(client, 'llama3.1:8b', { verbose: true })).toSucceed();
104
+ expect(show).toHaveBeenCalledWith({ model: 'llama3.1:8b', verbose: true });
105
+ });
106
+
107
+ test('fails when the client throws (unknown model)', async () => {
108
+ const client = mockClient({ show: jest.fn().mockRejectedValue(new Error('model not found')) });
109
+ expect(await showModel(client, 'nope:latest')).toFailWith(/model not found/);
110
+ });
111
+ });
112
+
113
+ describe('deleteModel', () => {
114
+ test('succeeds with a meaningful result and forwards the model name', async () => {
115
+ const del = jest.fn().mockResolvedValue({ status: 'success' });
116
+ const client = mockClient({ delete: del });
117
+ expect(await deleteModel(client, 'llama3.1:8b')).toSucceedWith({
118
+ model: 'llama3.1:8b',
119
+ deleted: true
120
+ });
121
+ expect(del).toHaveBeenCalledWith({ model: 'llama3.1:8b' });
122
+ });
123
+
124
+ test('fails when the client throws (missing model)', async () => {
125
+ const client = mockClient({ delete: jest.fn().mockRejectedValue(new Error('model not found')) });
126
+ expect(await deleteModel(client, 'nope:latest')).toFailWith(/model not found/);
127
+ });
128
+ });
@@ -0,0 +1,44 @@
1
+ import '@fgv/ts-utils-jest';
2
+ import { Ollama } from 'ollama';
3
+ import { createOllamaClient } from '../../index';
4
+
5
+ /**
6
+ * Reads the upstream client's (protected) resolved host for assertion. The `Ollama` instance
7
+ * keeps its config on a protected field; tests reach it through a narrow cast rather than widening
8
+ * the production surface.
9
+ */
10
+ function resolvedHost(client: unknown): string {
11
+ return (client as { config: { host: string } }).config.host;
12
+ }
13
+
14
+ describe('createOllamaClient', () => {
15
+ test('succeeds with default host when no params are supplied', () => {
16
+ expect(createOllamaClient()).toSucceedAndSatisfy((client) => {
17
+ expect(client).toBeInstanceOf(Ollama);
18
+ expect(resolvedHost(client)).toBe('http://127.0.0.1:11434');
19
+ });
20
+ });
21
+
22
+ test('applies a custom host', () => {
23
+ expect(createOllamaClient({ host: 'http://10.0.0.5:11434' })).toSucceedAndSatisfy((client) => {
24
+ expect(resolvedHost(client)).toBe('http://10.0.0.5:11434');
25
+ });
26
+ });
27
+
28
+ test('accepts a custom fetch and headers', () => {
29
+ const customFetch = jest.fn() as unknown as typeof fetch;
30
+ expect(
31
+ createOllamaClient({
32
+ host: 'http://localhost:11434',
33
+ fetch: customFetch,
34
+ headers: { Authorization: 'Bearer token' }
35
+ })
36
+ ).toSucceedAndSatisfy((client) => {
37
+ expect(client).toBeInstanceOf(Ollama);
38
+ });
39
+ });
40
+
41
+ test('fails when the host is malformed (upstream constructor throws)', () => {
42
+ expect(createOllamaClient({ host: 'not a url' })).toFailWith(/invalid url/i);
43
+ });
44
+ });