@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,287 @@
1
+ var __await = (this && this.__await) || function (v) { return this instanceof __await ? (this.v = v, this) : new __await(v); }
2
+ var __asyncGenerator = (this && this.__asyncGenerator) || function (thisArg, _arguments, generator) {
3
+ if (!Symbol.asyncIterator) throw new TypeError("Symbol.asyncIterator is not defined.");
4
+ var g = generator.apply(thisArg, _arguments || []), i, q = [];
5
+ return i = Object.create((typeof AsyncIterator === "function" ? AsyncIterator : Object).prototype), verb("next"), verb("throw"), verb("return", awaitReturn), i[Symbol.asyncIterator] = function () { return this; }, i;
6
+ function awaitReturn(f) { return function (v) { return Promise.resolve(v).then(f, reject); }; }
7
+ function verb(n, f) { if (g[n]) { i[n] = function (v) { return new Promise(function (a, b) { q.push([n, v, a, b]) > 1 || resume(n, v); }); }; if (f) i[n] = f(i[n]); } }
8
+ function resume(n, v) { try { step(g[n](v)); } catch (e) { settle(q[0][3], e); } }
9
+ function step(r) { r.value instanceof __await ? Promise.resolve(r.value.v).then(fulfill, reject) : settle(q[0][2], r); }
10
+ function fulfill(value) { resume("next", value); }
11
+ function reject(value) { resume("throw", value); }
12
+ function settle(f, v) { if (f(v), q.shift(), q.length) resume(q[0][0], q[0][1]); }
13
+ };
14
+ import '@fgv/ts-utils-jest';
15
+ import { succeed } from '@fgv/ts-utils';
16
+ import { JsonSchema } from '@fgv/ts-json-base';
17
+ import { chatStructured, createOllamaClient } from '../../index';
18
+ const personSchema = JsonSchema.object({
19
+ name: JsonSchema.string(),
20
+ age: JsonSchema.integer()
21
+ });
22
+ /**
23
+ * Builds a structural mock of the upstream `AbortableAsyncIterator<ChatResponse>` returned by
24
+ * `client.chat({ stream: true })`. `abort()` flips a flag the generator checks before each chunk.
25
+ */
26
+ function makeChatIterator(chunks) {
27
+ let aborted = false;
28
+ return {
29
+ abort: jest.fn(() => {
30
+ aborted = true;
31
+ }),
32
+ [Symbol.asyncIterator]() {
33
+ return __asyncGenerator(this, arguments, function* _a() {
34
+ for (const chunk of chunks) {
35
+ if (aborted) {
36
+ throw new Error('The operation was aborted');
37
+ }
38
+ yield yield __await(chunk);
39
+ }
40
+ });
41
+ }
42
+ };
43
+ }
44
+ /**
45
+ * Like {@link makeChatIterator}, but yields control (a real timer) after each chunk so a signal
46
+ * aborted *after* the listener is attached lands mid-stream — exercising the abort-listener path
47
+ * (vs. the already-aborted branch).
48
+ */
49
+ function makeDelayedChatIterator(chunks) {
50
+ let aborted = false;
51
+ return {
52
+ abort: jest.fn(() => {
53
+ aborted = true;
54
+ }),
55
+ [Symbol.asyncIterator]() {
56
+ return __asyncGenerator(this, arguments, function* _a() {
57
+ for (const chunk of chunks) {
58
+ if (aborted) {
59
+ throw new Error('The operation was aborted');
60
+ }
61
+ yield yield __await(chunk);
62
+ yield __await(new Promise((resolve) => setTimeout(resolve, 30)));
63
+ }
64
+ });
65
+ }
66
+ };
67
+ }
68
+ function mockChatClient(chat) {
69
+ return { chat };
70
+ }
71
+ /** Chunks whose assembled `message.content` is `{"name":"ada","age":42}`. */
72
+ function validPersonChunks(model = 'llama3.1:8b') {
73
+ return [
74
+ { model, message: { role: 'assistant', content: '{"name":"ada",' }, done: false },
75
+ { model, message: { role: 'assistant', content: '"age":42}' }, done: false },
76
+ { model, message: { role: 'assistant', content: '' }, done: true, done_reason: 'stop' }
77
+ ];
78
+ }
79
+ const userTurn = [{ role: 'user', content: 'Describe Ada.' }];
80
+ describe('chatStructured (layer 1 — structural client mock)', () => {
81
+ test('validates the assembled document against the same schema sent on the wire', async () => {
82
+ const chat = jest.fn().mockResolvedValue(makeChatIterator(validPersonChunks()));
83
+ const client = mockChatClient(chat);
84
+ expect(await chatStructured(client, { model: 'llama3.1:8b', messages: userTurn, schema: personSchema })).toSucceedAndSatisfy((result) => {
85
+ const value = result.value;
86
+ expect(value).toEqual({ name: 'ada', age: 42 });
87
+ expect(result.raw).toBe('{"name":"ada","age":42}');
88
+ expect(result.model).toBe('llama3.1:8b');
89
+ expect(result.doneReason).toBe('stop');
90
+ });
91
+ // The wire request: streaming, messages mapped, and the sanitized schema as `format`.
92
+ const request = chat.mock.calls[0][0];
93
+ expect(request.stream).toBe(true);
94
+ expect(request.messages).toEqual([{ role: 'user', content: 'Describe Ada.' }]);
95
+ const format = request.format;
96
+ expect(format.type).toBe('object');
97
+ expect(format.properties).toEqual({ name: { type: 'string' }, age: { type: 'integer' } });
98
+ expect(format.required).toEqual(['name', 'age']);
99
+ // Draft-07-only keywords are stripped before hitting Ollama's `format`.
100
+ expect(format).not.toHaveProperty('additionalProperties');
101
+ expect(format).not.toHaveProperty('$schema');
102
+ // No options / keep_alive were supplied.
103
+ expect(request).not.toHaveProperty('options');
104
+ expect(request).not.toHaveProperty('keep_alive');
105
+ });
106
+ test('forwards options, keep_alive, and base64 images verbatim', async () => {
107
+ const chat = jest.fn().mockResolvedValue(makeChatIterator(validPersonChunks()));
108
+ const client = mockChatClient(chat);
109
+ const messages = [
110
+ { role: 'system', content: 'Be terse.' },
111
+ { role: 'user', content: 'Look', images: ['aGVsbG8='] }
112
+ ];
113
+ expect(await chatStructured(client, {
114
+ model: 'llama3.1:8b',
115
+ messages,
116
+ schema: personSchema,
117
+ options: { temperature: 0, seed: 7 },
118
+ keepAlive: '5m'
119
+ })).toSucceed();
120
+ const request = chat.mock.calls[0][0];
121
+ expect(request.options).toEqual({ temperature: 0, seed: 7 });
122
+ expect(request.keep_alive).toBe('5m');
123
+ expect(request.messages).toEqual([
124
+ { role: 'system', content: 'Be terse.' },
125
+ { role: 'user', content: 'Look', images: ['aGVsbG8='] }
126
+ ]);
127
+ });
128
+ test('fails with context when the response parses but violates the schema', async () => {
129
+ const chunks = [
130
+ {
131
+ model: 'llama3.1:8b',
132
+ message: { role: 'assistant', content: '{"name":"ada","age":"old"}' },
133
+ done: true
134
+ }
135
+ ];
136
+ const client = mockChatClient(jest.fn().mockResolvedValue(makeChatIterator(chunks)));
137
+ expect(await chatStructured(client, { model: 'llama3.1:8b', messages: userTurn, schema: personSchema })).toFailWith(/chatStructured\(llama3\.1:8b\): response failed schema validation/i);
138
+ });
139
+ test('fails with context when the response is not valid JSON', async () => {
140
+ const chunks = [
141
+ { model: 'llama3.1:8b', message: { role: 'assistant', content: 'not json at all' }, done: true }
142
+ ];
143
+ const client = mockChatClient(jest.fn().mockResolvedValue(makeChatIterator(chunks)));
144
+ expect(await chatStructured(client, { model: 'llama3.1:8b', messages: userTurn, schema: personSchema })).toFailWith(/chatStructured\(llama3\.1:8b\): response was not valid JSON/i);
145
+ });
146
+ test('an already-aborted signal cancels the generation', async () => {
147
+ const iterator = makeChatIterator(validPersonChunks());
148
+ const client = mockChatClient(jest.fn().mockResolvedValue(iterator));
149
+ const controller = new AbortController();
150
+ controller.abort();
151
+ expect(await chatStructured(client, {
152
+ model: 'llama3.1:8b',
153
+ messages: userTurn,
154
+ schema: personSchema,
155
+ signal: controller.signal
156
+ })).toFailWith(/abort/i);
157
+ expect(iterator.abort).toHaveBeenCalledTimes(1);
158
+ });
159
+ test('a mid-stream abort cancels the generation and removes the listener', async () => {
160
+ const iterator = makeDelayedChatIterator(validPersonChunks());
161
+ const client = mockChatClient(jest.fn().mockResolvedValue(iterator));
162
+ const controller = new AbortController();
163
+ const removeSpy = jest.spyOn(controller.signal, 'removeEventListener');
164
+ const promise = chatStructured(client, {
165
+ model: 'llama3.1:8b',
166
+ messages: userTurn,
167
+ schema: personSchema,
168
+ signal: controller.signal
169
+ });
170
+ // Let the request settle and the listener attach + first chunk land, then abort mid-stream so
171
+ // the abort fires through the listener (not the already-aborted branch).
172
+ await new Promise((resolve) => setTimeout(resolve, 5));
173
+ controller.abort();
174
+ expect(await promise).toFailWith(/abort/i);
175
+ expect(iterator.abort).toHaveBeenCalled();
176
+ expect(removeSpy).toHaveBeenCalledWith('abort', expect.any(Function));
177
+ });
178
+ test('removes the abort listener after a successful completion', async () => {
179
+ const client = mockChatClient(jest.fn().mockResolvedValue(makeChatIterator(validPersonChunks())));
180
+ const controller = new AbortController();
181
+ const removeSpy = jest.spyOn(controller.signal, 'removeEventListener');
182
+ expect(await chatStructured(client, {
183
+ model: 'llama3.1:8b',
184
+ messages: userTurn,
185
+ schema: personSchema,
186
+ signal: controller.signal
187
+ })).toSucceed();
188
+ expect(removeSpy).toHaveBeenCalledWith('abort', expect.any(Function));
189
+ });
190
+ });
191
+ describe('chatStructured — draft-07 format sanitizer', () => {
192
+ test('recursively strips $schema and additionalProperties while preserving property names and arrays', async () => {
193
+ // A schema double whose toJson() carries $schema, nested additionalProperties, an array, and a
194
+ // property *named* additionalProperties (which must survive — it is a name, not a keyword).
195
+ const rawSchema = {
196
+ $schema: 'http://json-schema.org/draft-07/schema#',
197
+ type: 'object',
198
+ properties: {
199
+ nested: {
200
+ type: 'object',
201
+ properties: { x: { type: 'string' } },
202
+ additionalProperties: false
203
+ },
204
+ additionalProperties: { type: 'string' }
205
+ },
206
+ required: ['nested'],
207
+ additionalProperties: false
208
+ };
209
+ const schemaDouble = {
210
+ _type: 'object',
211
+ toJson: () => rawSchema,
212
+ validate: (value) => succeed(value)
213
+ };
214
+ const chat = jest
215
+ .fn()
216
+ .mockResolvedValue(makeChatIterator([
217
+ { model: 'm', message: { role: 'assistant', content: '{"nested":{"x":"y"}}' }, done: true }
218
+ ]));
219
+ const client = mockChatClient(chat);
220
+ expect(await chatStructured(client, { model: 'm', messages: userTurn, schema: schemaDouble })).toSucceed();
221
+ const format = chat.mock.calls[0][0].format;
222
+ expect(format).not.toHaveProperty('$schema');
223
+ expect(format).not.toHaveProperty('additionalProperties');
224
+ expect(format.required).toEqual(['nested']);
225
+ const properties = format.properties;
226
+ // The nested object's additionalProperties is stripped too.
227
+ expect(properties.nested).toEqual({ type: 'object', properties: { x: { type: 'string' } } });
228
+ // A property literally named `additionalProperties` is preserved (name, not keyword).
229
+ expect(properties.additionalProperties).toEqual({ type: 'string' });
230
+ });
231
+ });
232
+ /**
233
+ * Layer 2 — a real client over a mock `fetch` that streams a newline-delimited `/api/chat`
234
+ * response. Proves the no-drift claim end-to-end: the request body that actually goes on the wire
235
+ * carries `format` = the sanitized `schema.toJson()`, and the assembled reply validates.
236
+ */
237
+ function ndjsonResponse(lines) {
238
+ const bytes = new TextEncoder().encode(lines.join('\n') + '\n');
239
+ const mid = Math.floor(bytes.length / 2);
240
+ const parts = [bytes.slice(0, mid), bytes.slice(mid)];
241
+ let i = 0;
242
+ const stream = new ReadableStream({
243
+ pull(controller) {
244
+ if (i < parts.length) {
245
+ controller.enqueue(parts[i++]);
246
+ }
247
+ else {
248
+ controller.close();
249
+ }
250
+ }
251
+ });
252
+ return new Response(stream, { status: 200 });
253
+ }
254
+ describe('chatStructured (layer 2 — fetch-level wire-format integration)', () => {
255
+ test('sends the sanitized schema as the wire format and validates the assembled reply', async () => {
256
+ const lines = [
257
+ JSON.stringify({
258
+ model: 'llama3.1:8b',
259
+ message: { role: 'assistant', content: '{"name":"ada",' },
260
+ done: false
261
+ }),
262
+ JSON.stringify({
263
+ model: 'llama3.1:8b',
264
+ message: { role: 'assistant', content: '"age":42}' },
265
+ done: true,
266
+ done_reason: 'stop'
267
+ })
268
+ ];
269
+ const fetchMock = jest.fn().mockResolvedValue(ndjsonResponse(lines));
270
+ const client = createOllamaClient({ fetch: fetchMock }).orThrow();
271
+ expect(await chatStructured(client, { model: 'llama3.1:8b', messages: userTurn, schema: personSchema })).toSucceedAndSatisfy((result) => {
272
+ expect(result.value).toEqual({ name: 'ada', age: 42 });
273
+ expect(result.raw).toBe('{"name":"ada","age":42}');
274
+ expect(result.doneReason).toBe('stop');
275
+ });
276
+ // The actual wire body carries the sanitized schema as `format` — no-drift proven end-to-end.
277
+ const requestInit = fetchMock.mock.calls[0][1];
278
+ const body = JSON.parse(requestInit.body);
279
+ expect(body.stream).toBe(true);
280
+ const format = body.format;
281
+ expect(format.type).toBe('object');
282
+ expect(format.properties).toEqual({ name: { type: 'string' }, age: { type: 'integer' } });
283
+ expect(format).not.toHaveProperty('additionalProperties');
284
+ expect(format).not.toHaveProperty('$schema');
285
+ });
286
+ });
287
+ //# sourceMappingURL=chatStructured.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"chatStructured.test.js","sourceRoot":"","sources":["../../../src/test/unit/chatStructured.test.ts"],"names":[],"mappings":"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","sourcesContent":["import '@fgv/ts-utils-jest';\nimport { succeed } from '@fgv/ts-utils';\nimport { JsonSchema, type JsonObject } from '@fgv/ts-json-base';\nimport { type IOllamaChatMessage, type IOllamaClient, chatStructured, createOllamaClient } from '../../index';\n\nconst personSchema = JsonSchema.object({\n name: JsonSchema.string(),\n age: JsonSchema.integer()\n});\ntype Person = JsonSchema.Static<typeof personSchema>;\n\n/** A streaming `/api/chat` chunk in the upstream `ChatResponse` shape (the fields we consume). */\ninterface IChatChunk {\n model: string;\n message: { role: string; content: string };\n done: boolean;\n done_reason?: string;\n}\n\n/**\n * Builds a structural mock of the upstream `AbortableAsyncIterator<ChatResponse>` returned by\n * `client.chat({ stream: true })`. `abort()` flips a flag the generator checks before each chunk.\n */\nfunction makeChatIterator(\n chunks: ReadonlyArray<IChatChunk>\n): { abort: jest.Mock } & AsyncIterable<IChatChunk> {\n let aborted = false;\n return {\n abort: jest.fn(() => {\n aborted = true;\n }),\n async *[Symbol.asyncIterator](): AsyncGenerator<IChatChunk> {\n for (const chunk of chunks) {\n if (aborted) {\n throw new Error('The operation was aborted');\n }\n yield chunk;\n }\n }\n };\n}\n\n/**\n * Like {@link makeChatIterator}, but yields control (a real timer) after each chunk so a signal\n * aborted *after* the listener is attached lands mid-stream — exercising the abort-listener path\n * (vs. the already-aborted branch).\n */\nfunction makeDelayedChatIterator(\n chunks: ReadonlyArray<IChatChunk>\n): { abort: jest.Mock } & AsyncIterable<IChatChunk> {\n let aborted = false;\n return {\n abort: jest.fn(() => {\n aborted = true;\n }),\n async *[Symbol.asyncIterator](): AsyncGenerator<IChatChunk> {\n for (const chunk of chunks) {\n if (aborted) {\n throw new Error('The operation was aborted');\n }\n yield chunk;\n await new Promise<void>((resolve) => setTimeout(resolve, 30));\n }\n }\n };\n}\n\nfunction mockChatClient(chat: unknown): IOllamaClient {\n return { chat } as unknown as IOllamaClient;\n}\n\n/** Chunks whose assembled `message.content` is `{\"name\":\"ada\",\"age\":42}`. */\nfunction validPersonChunks(model: string = 'llama3.1:8b'): IChatChunk[] {\n return [\n { model, message: { role: 'assistant', content: '{\"name\":\"ada\",' }, done: false },\n { model, message: { role: 'assistant', content: '\"age\":42}' }, done: false },\n { model, message: { role: 'assistant', content: '' }, done: true, done_reason: 'stop' }\n ];\n}\n\nconst userTurn: ReadonlyArray<IOllamaChatMessage> = [{ role: 'user', content: 'Describe Ada.' }];\n\ndescribe('chatStructured (layer 1 — structural client mock)', () => {\n test('validates the assembled document against the same schema sent on the wire', async () => {\n const chat = jest.fn().mockResolvedValue(makeChatIterator(validPersonChunks()));\n const client = mockChatClient(chat);\n\n expect(\n await chatStructured(client, { model: 'llama3.1:8b', messages: userTurn, schema: personSchema })\n ).toSucceedAndSatisfy((result) => {\n const value: Person = result.value;\n expect(value).toEqual({ name: 'ada', age: 42 });\n expect(result.raw).toBe('{\"name\":\"ada\",\"age\":42}');\n expect(result.model).toBe('llama3.1:8b');\n expect(result.doneReason).toBe('stop');\n });\n\n // The wire request: streaming, messages mapped, and the sanitized schema as `format`.\n const request = chat.mock.calls[0][0] as JsonObject;\n expect(request.stream).toBe(true);\n expect(request.messages).toEqual([{ role: 'user', content: 'Describe Ada.' }]);\n const format = request.format as JsonObject;\n expect(format.type).toBe('object');\n expect(format.properties).toEqual({ name: { type: 'string' }, age: { type: 'integer' } });\n expect(format.required).toEqual(['name', 'age']);\n // Draft-07-only keywords are stripped before hitting Ollama's `format`.\n expect(format).not.toHaveProperty('additionalProperties');\n expect(format).not.toHaveProperty('$schema');\n // No options / keep_alive were supplied.\n expect(request).not.toHaveProperty('options');\n expect(request).not.toHaveProperty('keep_alive');\n });\n\n test('forwards options, keep_alive, and base64 images verbatim', async () => {\n const chat = jest.fn().mockResolvedValue(makeChatIterator(validPersonChunks()));\n const client = mockChatClient(chat);\n const messages: ReadonlyArray<IOllamaChatMessage> = [\n { role: 'system', content: 'Be terse.' },\n { role: 'user', content: 'Look', images: ['aGVsbG8='] }\n ];\n\n expect(\n await chatStructured(client, {\n model: 'llama3.1:8b',\n messages,\n schema: personSchema,\n options: { temperature: 0, seed: 7 },\n keepAlive: '5m'\n })\n ).toSucceed();\n\n const request = chat.mock.calls[0][0] as JsonObject;\n expect(request.options).toEqual({ temperature: 0, seed: 7 });\n expect(request.keep_alive).toBe('5m');\n expect(request.messages).toEqual([\n { role: 'system', content: 'Be terse.' },\n { role: 'user', content: 'Look', images: ['aGVsbG8='] }\n ]);\n });\n\n test('fails with context when the response parses but violates the schema', async () => {\n const chunks: IChatChunk[] = [\n {\n model: 'llama3.1:8b',\n message: { role: 'assistant', content: '{\"name\":\"ada\",\"age\":\"old\"}' },\n done: true\n }\n ];\n const client = mockChatClient(jest.fn().mockResolvedValue(makeChatIterator(chunks)));\n\n expect(\n await chatStructured(client, { model: 'llama3.1:8b', messages: userTurn, schema: personSchema })\n ).toFailWith(/chatStructured\\(llama3\\.1:8b\\): response failed schema validation/i);\n });\n\n test('fails with context when the response is not valid JSON', async () => {\n const chunks: IChatChunk[] = [\n { model: 'llama3.1:8b', message: { role: 'assistant', content: 'not json at all' }, done: true }\n ];\n const client = mockChatClient(jest.fn().mockResolvedValue(makeChatIterator(chunks)));\n\n expect(\n await chatStructured(client, { model: 'llama3.1:8b', messages: userTurn, schema: personSchema })\n ).toFailWith(/chatStructured\\(llama3\\.1:8b\\): response was not valid JSON/i);\n });\n\n test('an already-aborted signal cancels the generation', async () => {\n const iterator = makeChatIterator(validPersonChunks());\n const client = mockChatClient(jest.fn().mockResolvedValue(iterator));\n const controller = new AbortController();\n controller.abort();\n\n expect(\n await chatStructured(client, {\n model: 'llama3.1:8b',\n messages: userTurn,\n schema: personSchema,\n signal: controller.signal\n })\n ).toFailWith(/abort/i);\n expect(iterator.abort).toHaveBeenCalledTimes(1);\n });\n\n test('a mid-stream abort cancels the generation and removes the listener', async () => {\n const iterator = makeDelayedChatIterator(validPersonChunks());\n const client = mockChatClient(jest.fn().mockResolvedValue(iterator));\n const controller = new AbortController();\n const removeSpy = jest.spyOn(controller.signal, 'removeEventListener');\n\n const promise = chatStructured(client, {\n model: 'llama3.1:8b',\n messages: userTurn,\n schema: personSchema,\n signal: controller.signal\n });\n // Let the request settle and the listener attach + first chunk land, then abort mid-stream so\n // the abort fires through the listener (not the already-aborted branch).\n await new Promise<void>((resolve) => setTimeout(resolve, 5));\n controller.abort();\n\n expect(await promise).toFailWith(/abort/i);\n expect(iterator.abort).toHaveBeenCalled();\n expect(removeSpy).toHaveBeenCalledWith('abort', expect.any(Function));\n });\n\n test('removes the abort listener after a successful completion', async () => {\n const client = mockChatClient(jest.fn().mockResolvedValue(makeChatIterator(validPersonChunks())));\n const controller = new AbortController();\n const removeSpy = jest.spyOn(controller.signal, 'removeEventListener');\n\n expect(\n await chatStructured(client, {\n model: 'llama3.1:8b',\n messages: userTurn,\n schema: personSchema,\n signal: controller.signal\n })\n ).toSucceed();\n expect(removeSpy).toHaveBeenCalledWith('abort', expect.any(Function));\n });\n});\n\ndescribe('chatStructured — draft-07 format sanitizer', () => {\n test('recursively strips $schema and additionalProperties while preserving property names and arrays', async () => {\n // A schema double whose toJson() carries $schema, nested additionalProperties, an array, and a\n // property *named* additionalProperties (which must survive — it is a name, not a keyword).\n const rawSchema: JsonObject = {\n $schema: 'http://json-schema.org/draft-07/schema#',\n type: 'object',\n properties: {\n nested: {\n type: 'object',\n properties: { x: { type: 'string' } },\n additionalProperties: false\n },\n additionalProperties: { type: 'string' }\n },\n required: ['nested'],\n additionalProperties: false\n };\n const schemaDouble = {\n _type: 'object',\n toJson: () => rawSchema,\n validate: (value: unknown) => succeed(value)\n } as unknown as JsonSchema.ISchemaValidator<unknown>;\n\n const chat = jest\n .fn()\n .mockResolvedValue(\n makeChatIterator([\n { model: 'm', message: { role: 'assistant', content: '{\"nested\":{\"x\":\"y\"}}' }, done: true }\n ])\n );\n const client = mockChatClient(chat);\n\n expect(\n await chatStructured(client, { model: 'm', messages: userTurn, schema: schemaDouble })\n ).toSucceed();\n\n const format = (chat.mock.calls[0][0] as JsonObject).format as JsonObject;\n expect(format).not.toHaveProperty('$schema');\n expect(format).not.toHaveProperty('additionalProperties');\n expect(format.required).toEqual(['nested']);\n const properties = format.properties as JsonObject;\n // The nested object's additionalProperties is stripped too.\n expect(properties.nested).toEqual({ type: 'object', properties: { x: { type: 'string' } } });\n // A property literally named `additionalProperties` is preserved (name, not keyword).\n expect(properties.additionalProperties).toEqual({ type: 'string' });\n });\n});\n\n/**\n * Layer 2 — a real client over a mock `fetch` that streams a newline-delimited `/api/chat`\n * response. Proves the no-drift claim end-to-end: the request body that actually goes on the wire\n * carries `format` = the sanitized `schema.toJson()`, and the assembled reply validates.\n */\nfunction ndjsonResponse(lines: ReadonlyArray<string>): Response {\n const bytes = new TextEncoder().encode(lines.join('\\n') + '\\n');\n const mid = Math.floor(bytes.length / 2);\n const parts = [bytes.slice(0, mid), bytes.slice(mid)];\n let i = 0;\n const stream = new ReadableStream<Uint8Array>({\n pull(controller: ReadableStreamDefaultController<Uint8Array>): void {\n if (i < parts.length) {\n controller.enqueue(parts[i++]);\n } else {\n controller.close();\n }\n }\n });\n return new Response(stream, { status: 200 });\n}\n\ndescribe('chatStructured (layer 2 — fetch-level wire-format integration)', () => {\n test('sends the sanitized schema as the wire format and validates the assembled reply', async () => {\n const lines = [\n JSON.stringify({\n model: 'llama3.1:8b',\n message: { role: 'assistant', content: '{\"name\":\"ada\",' },\n done: false\n }),\n JSON.stringify({\n model: 'llama3.1:8b',\n message: { role: 'assistant', content: '\"age\":42}' },\n done: true,\n done_reason: 'stop'\n })\n ];\n const fetchMock = jest.fn().mockResolvedValue(ndjsonResponse(lines));\n const client = createOllamaClient({ fetch: fetchMock as unknown as typeof fetch }).orThrow();\n\n expect(\n await chatStructured(client, { model: 'llama3.1:8b', messages: userTurn, schema: personSchema })\n ).toSucceedAndSatisfy((result) => {\n expect(result.value).toEqual({ name: 'ada', age: 42 });\n expect(result.raw).toBe('{\"name\":\"ada\",\"age\":42}');\n expect(result.doneReason).toBe('stop');\n });\n\n // The actual wire body carries the sanitized schema as `format` — no-drift proven end-to-end.\n const requestInit = fetchMock.mock.calls[0][1] as { body: string };\n const body = JSON.parse(requestInit.body) as JsonObject;\n expect(body.stream).toBe(true);\n const format = body.format as JsonObject;\n expect(format.type).toBe('object');\n expect(format.properties).toEqual({ name: { type: 'string' }, age: { type: 'integer' } });\n expect(format).not.toHaveProperty('additionalProperties');\n expect(format).not.toHaveProperty('$schema');\n });\n});\n"]}
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Canned wire-shape fixtures for the layer-1 structural-client-mock tests.
3
+ *
4
+ * These mirror the actual JSON the Ollama daemon returns — snake_case keys and **string**
5
+ * timestamps. The upstream `ollama` types annotate `modified_at` / `expires_at` as `Date`, but the
6
+ * library returns `response.json()` verbatim so the wire delivers RFC3339 strings; each fixture is
7
+ * cast once (`as unknown as <Response>`) to reconcile the inaccurate upstream annotation with the
8
+ * real shape, so the normalization layer is exercised against the data it actually sees.
9
+ */
10
+ /** `GET /api/tags` — one fully-detailed model plus one with sparse `details` (missing fields). */
11
+ export const tagsResponse = {
12
+ models: [
13
+ {
14
+ name: 'llama3.1:8b',
15
+ model: 'llama3.1:8b',
16
+ modified_at: '2024-08-01T12:34:56.789Z',
17
+ size: 4661226402,
18
+ digest: 'sha256:abc123',
19
+ details: {
20
+ parent_model: '',
21
+ format: 'gguf',
22
+ family: 'llama',
23
+ families: ['llama'],
24
+ parameter_size: '8.0B',
25
+ quantization_level: 'Q4_0'
26
+ }
27
+ },
28
+ {
29
+ name: 'custom:latest',
30
+ model: 'custom:latest',
31
+ modified_at: '2024-09-15T08:00:00.000Z',
32
+ size: 1234567,
33
+ digest: 'sha256:def456',
34
+ // Sparse details — Ollama omits fields it cannot determine; exercises the optional-field path.
35
+ details: {
36
+ format: 'gguf',
37
+ family: 'llama'
38
+ }
39
+ }
40
+ ]
41
+ };
42
+ /** `GET /api/ps` — a running model with `expires_at` + `size_vram` (no `modified_at`). */
43
+ export const psResponse = {
44
+ models: [
45
+ {
46
+ name: 'llama3.1:8b',
47
+ model: 'llama3.1:8b',
48
+ size: 4661226402,
49
+ digest: 'sha256:abc123',
50
+ details: {
51
+ parent_model: '',
52
+ format: 'gguf',
53
+ family: 'llama',
54
+ families: ['llama'],
55
+ parameter_size: '8.0B',
56
+ quantization_level: 'Q4_0'
57
+ },
58
+ expires_at: '2024-08-01T13:00:00.000Z',
59
+ size_vram: 4661226402
60
+ }
61
+ ]
62
+ };
63
+ /** `POST /api/show` — full response including the low-level `model_info` metadata bag. */
64
+ export const showResponse = {
65
+ modelfile: 'FROM llama3.1:8b',
66
+ parameters: 'stop "<|eot_id|>"',
67
+ template: '{{ .Prompt }}',
68
+ details: {
69
+ parent_model: '',
70
+ format: 'gguf',
71
+ family: 'llama',
72
+ families: ['llama'],
73
+ parameter_size: '8.0B',
74
+ quantization_level: 'Q4_0'
75
+ },
76
+ model_info: {
77
+ 'general.architecture': 'llama',
78
+ 'llama.context_length': 131072,
79
+ 'llama.embedding_length': 4096
80
+ },
81
+ capabilities: ['completion', 'tools']
82
+ };
83
+ /** `POST /api/show` — a minimal response with `model_info` and `capabilities` omitted. */
84
+ export const showResponseMinimal = {
85
+ details: {
86
+ format: 'gguf',
87
+ family: 'llama'
88
+ }
89
+ };
90
+ //# sourceMappingURL=wireFixtures.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"wireFixtures.js","sourceRoot":"","sources":["../../../../src/test/unit/fixtures/wireFixtures.ts"],"names":[],"mappings":"AAEA;;;;;;;;GAQG;AAEH,kGAAkG;AAClG,MAAM,CAAC,MAAM,YAAY,GAAiB;IACxC,MAAM,EAAE;QACN;YACE,IAAI,EAAE,aAAa;YACnB,KAAK,EAAE,aAAa;YACpB,WAAW,EAAE,0BAA0B;YACvC,IAAI,EAAE,UAAU;YAChB,MAAM,EAAE,eAAe;YACvB,OAAO,EAAE;gBACP,YAAY,EAAE,EAAE;gBAChB,MAAM,EAAE,MAAM;gBACd,MAAM,EAAE,OAAO;gBACf,QAAQ,EAAE,CAAC,OAAO,CAAC;gBACnB,cAAc,EAAE,MAAM;gBACtB,kBAAkB,EAAE,MAAM;aAC3B;SACF;QACD;YACE,IAAI,EAAE,eAAe;YACrB,KAAK,EAAE,eAAe;YACtB,WAAW,EAAE,0BAA0B;YACvC,IAAI,EAAE,OAAO;YACb,MAAM,EAAE,eAAe;YACvB,+FAA+F;YAC/F,OAAO,EAAE;gBACP,MAAM,EAAE,MAAM;gBACd,MAAM,EAAE,OAAO;aAChB;SACF;KACF;CACyB,CAAC;AAE7B,0FAA0F;AAC1F,MAAM,CAAC,MAAM,UAAU,GAAiB;IACtC,MAAM,EAAE;QACN;YACE,IAAI,EAAE,aAAa;YACnB,KAAK,EAAE,aAAa;YACpB,IAAI,EAAE,UAAU;YAChB,MAAM,EAAE,eAAe;YACvB,OAAO,EAAE;gBACP,YAAY,EAAE,EAAE;gBAChB,MAAM,EAAE,MAAM;gBACd,MAAM,EAAE,OAAO;gBACf,QAAQ,EAAE,CAAC,OAAO,CAAC;gBACnB,cAAc,EAAE,MAAM;gBACtB,kBAAkB,EAAE,MAAM;aAC3B;YACD,UAAU,EAAE,0BAA0B;YACtC,SAAS,EAAE,UAAU;SACtB;KACF;CACyB,CAAC;AAE7B,0FAA0F;AAC1F,MAAM,CAAC,MAAM,YAAY,GAAiB;IACxC,SAAS,EAAE,kBAAkB;IAC7B,UAAU,EAAE,mBAAmB;IAC/B,QAAQ,EAAE,eAAe;IACzB,OAAO,EAAE;QACP,YAAY,EAAE,EAAE;QAChB,MAAM,EAAE,MAAM;QACd,MAAM,EAAE,OAAO;QACf,QAAQ,EAAE,CAAC,OAAO,CAAC;QACnB,cAAc,EAAE,MAAM;QACtB,kBAAkB,EAAE,MAAM;KAC3B;IACD,UAAU,EAAE;QACV,sBAAsB,EAAE,OAAO;QAC/B,sBAAsB,EAAE,MAAM;QAC9B,wBAAwB,EAAE,IAAI;KAC/B;IACD,YAAY,EAAE,CAAC,YAAY,EAAE,OAAO,CAAC;CACX,CAAC;AAE7B,0FAA0F;AAC1F,MAAM,CAAC,MAAM,mBAAmB,GAAiB;IAC/C,OAAO,EAAE;QACP,MAAM,EAAE,MAAM;QACd,MAAM,EAAE,OAAO;KAChB;CACyB,CAAC","sourcesContent":["import { type ListResponse, type ShowResponse } from 'ollama';\n\n/**\n * Canned wire-shape fixtures for the layer-1 structural-client-mock tests.\n *\n * These mirror the actual JSON the Ollama daemon returns — snake_case keys and **string**\n * timestamps. The upstream `ollama` types annotate `modified_at` / `expires_at` as `Date`, but the\n * library returns `response.json()` verbatim so the wire delivers RFC3339 strings; each fixture is\n * cast once (`as unknown as <Response>`) to reconcile the inaccurate upstream annotation with the\n * real shape, so the normalization layer is exercised against the data it actually sees.\n */\n\n/** `GET /api/tags` — one fully-detailed model plus one with sparse `details` (missing fields). */\nexport const tagsResponse: ListResponse = {\n models: [\n {\n name: 'llama3.1:8b',\n model: 'llama3.1:8b',\n modified_at: '2024-08-01T12:34:56.789Z',\n size: 4661226402,\n digest: 'sha256:abc123',\n details: {\n parent_model: '',\n format: 'gguf',\n family: 'llama',\n families: ['llama'],\n parameter_size: '8.0B',\n quantization_level: 'Q4_0'\n }\n },\n {\n name: 'custom:latest',\n model: 'custom:latest',\n modified_at: '2024-09-15T08:00:00.000Z',\n size: 1234567,\n digest: 'sha256:def456',\n // Sparse details — Ollama omits fields it cannot determine; exercises the optional-field path.\n details: {\n format: 'gguf',\n family: 'llama'\n }\n }\n ]\n} as unknown as ListResponse;\n\n/** `GET /api/ps` — a running model with `expires_at` + `size_vram` (no `modified_at`). */\nexport const psResponse: ListResponse = {\n models: [\n {\n name: 'llama3.1:8b',\n model: 'llama3.1:8b',\n size: 4661226402,\n digest: 'sha256:abc123',\n details: {\n parent_model: '',\n format: 'gguf',\n family: 'llama',\n families: ['llama'],\n parameter_size: '8.0B',\n quantization_level: 'Q4_0'\n },\n expires_at: '2024-08-01T13:00:00.000Z',\n size_vram: 4661226402\n }\n ]\n} as unknown as ListResponse;\n\n/** `POST /api/show` — full response including the low-level `model_info` metadata bag. */\nexport const showResponse: ShowResponse = {\n modelfile: 'FROM llama3.1:8b',\n parameters: 'stop \"<|eot_id|>\"',\n template: '{{ .Prompt }}',\n details: {\n parent_model: '',\n format: 'gguf',\n family: 'llama',\n families: ['llama'],\n parameter_size: '8.0B',\n quantization_level: 'Q4_0'\n },\n model_info: {\n 'general.architecture': 'llama',\n 'llama.context_length': 131072,\n 'llama.embedding_length': 4096\n },\n capabilities: ['completion', 'tools']\n} as unknown as ShowResponse;\n\n/** `POST /api/show` — a minimal response with `model_info` and `capabilities` omitted. */\nexport const showResponseMinimal: ShowResponse = {\n details: {\n format: 'gguf',\n family: 'llama'\n }\n} as unknown as ShowResponse;\n"]}
@@ -0,0 +1,118 @@
1
+ import '@fgv/ts-utils-jest';
2
+ import { listModels, listRunning, showModel, deleteModel } from '../../index';
3
+ import { psResponse, showResponse, showResponseMinimal, tagsResponse } from './fixtures/wireFixtures';
4
+ /**
5
+ * Builds a structural client mock exposing only the methods under test, typed back to
6
+ * {@link IOllamaClient}. This is the layer-1 seam from the design (§7) — it verifies the wire→fgv
7
+ * mapping and Result success/failure paths without HTTP or the `ollama` lib internals.
8
+ */
9
+ function mockClient(methods) {
10
+ return methods;
11
+ }
12
+ describe('listModels', () => {
13
+ test('normalizes /api/tags entries to camelCase with a parsed modifiedAt Date', async () => {
14
+ const client = mockClient({ list: jest.fn().mockResolvedValue(tagsResponse) });
15
+ expect(await listModels(client)).toSucceedAndSatisfy((models) => {
16
+ expect(models).toHaveLength(2);
17
+ const [full, sparse] = models;
18
+ expect(full.name).toBe('llama3.1:8b');
19
+ expect(full.size).toBe(4661226402);
20
+ expect(full.digest).toBe('sha256:abc123');
21
+ expect(full.modifiedAt).toBeInstanceOf(Date);
22
+ expect(full.modifiedAt.toISOString()).toBe('2024-08-01T12:34:56.789Z');
23
+ expect(full.details).toEqual({
24
+ parentModel: '',
25
+ format: 'gguf',
26
+ family: 'llama',
27
+ families: ['llama'],
28
+ parameterSize: '8.0B',
29
+ quantizationLevel: 'Q4_0'
30
+ });
31
+ // An empty `parent_model` is preserved as '' (present-and-empty, distinct from undefined).
32
+ expect(full.details.parentModel).toBe('');
33
+ // Sparse details: omitted wire fields normalize to undefined.
34
+ expect(sparse.details.format).toBe('gguf');
35
+ expect(sparse.details.parameterSize).toBeUndefined();
36
+ expect(sparse.details.quantizationLevel).toBeUndefined();
37
+ expect(sparse.details.parentModel).toBeUndefined();
38
+ expect(sparse.details.families).toBeUndefined();
39
+ });
40
+ });
41
+ test('fails when the client throws', async () => {
42
+ const client = mockClient({ list: jest.fn().mockRejectedValue(new Error('connection refused')) });
43
+ expect(await listModels(client)).toFailWith(/connection refused/);
44
+ });
45
+ });
46
+ describe('listRunning', () => {
47
+ test('normalizes /api/ps entries with expiresAt Date and sizeVram', async () => {
48
+ const client = mockClient({ ps: jest.fn().mockResolvedValue(psResponse) });
49
+ expect(await listRunning(client)).toSucceedAndSatisfy((running) => {
50
+ expect(running).toHaveLength(1);
51
+ const [model] = running;
52
+ expect(model.name).toBe('llama3.1:8b');
53
+ expect(model.sizeVram).toBe(4661226402);
54
+ expect(model.expiresAt).toBeInstanceOf(Date);
55
+ expect(model.expiresAt.toISOString()).toBe('2024-08-01T13:00:00.000Z');
56
+ expect(model.details.family).toBe('llama');
57
+ });
58
+ });
59
+ test('fails when the client throws', async () => {
60
+ const client = mockClient({ ps: jest.fn().mockRejectedValue(new Error('daemon down')) });
61
+ expect(await listRunning(client)).toFailWith(/daemon down/);
62
+ });
63
+ });
64
+ describe('showModel', () => {
65
+ test('normalizes /api/show including the model_info metadata bag', async () => {
66
+ const show = jest.fn().mockResolvedValue(showResponse);
67
+ const client = mockClient({ show });
68
+ expect(await showModel(client, 'llama3.1:8b')).toSucceedAndSatisfy((info) => {
69
+ expect(info.modelfile).toBe('FROM llama3.1:8b');
70
+ expect(info.parameters).toBe('stop "<|eot_id|>"');
71
+ expect(info.template).toBe('{{ .Prompt }}');
72
+ expect(info.capabilities).toEqual(['completion', 'tools']);
73
+ expect(info.modelInfo).toEqual({
74
+ 'general.architecture': 'llama',
75
+ 'llama.context_length': 131072,
76
+ 'llama.embedding_length': 4096
77
+ });
78
+ expect(info.details.parameterSize).toBe('8.0B');
79
+ });
80
+ // No verbose flag was requested, so only `model` is on the request body.
81
+ expect(show).toHaveBeenCalledWith({ model: 'llama3.1:8b' });
82
+ });
83
+ test('maps an absent model_info to undefined', async () => {
84
+ const client = mockClient({ show: jest.fn().mockResolvedValue(showResponseMinimal) });
85
+ expect(await showModel(client, 'custom:latest')).toSucceedAndSatisfy((info) => {
86
+ expect(info.modelInfo).toBeUndefined();
87
+ expect(info.capabilities).toBeUndefined();
88
+ expect(info.modelfile).toBeUndefined();
89
+ expect(info.details.family).toBe('llama');
90
+ });
91
+ });
92
+ test('forwards verbose: true on the /api/show request body', async () => {
93
+ const show = jest.fn().mockResolvedValue(showResponse);
94
+ const client = mockClient({ show });
95
+ expect(await showModel(client, 'llama3.1:8b', { verbose: true })).toSucceed();
96
+ expect(show).toHaveBeenCalledWith({ model: 'llama3.1:8b', verbose: true });
97
+ });
98
+ test('fails when the client throws (unknown model)', async () => {
99
+ const client = mockClient({ show: jest.fn().mockRejectedValue(new Error('model not found')) });
100
+ expect(await showModel(client, 'nope:latest')).toFailWith(/model not found/);
101
+ });
102
+ });
103
+ describe('deleteModel', () => {
104
+ test('succeeds with a meaningful result and forwards the model name', async () => {
105
+ const del = jest.fn().mockResolvedValue({ status: 'success' });
106
+ const client = mockClient({ delete: del });
107
+ expect(await deleteModel(client, 'llama3.1:8b')).toSucceedWith({
108
+ model: 'llama3.1:8b',
109
+ deleted: true
110
+ });
111
+ expect(del).toHaveBeenCalledWith({ model: 'llama3.1:8b' });
112
+ });
113
+ test('fails when the client throws (missing model)', async () => {
114
+ const client = mockClient({ delete: jest.fn().mockRejectedValue(new Error('model not found')) });
115
+ expect(await deleteModel(client, 'nope:latest')).toFailWith(/model not found/);
116
+ });
117
+ });
118
+ //# sourceMappingURL=modelManagement.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"modelManagement.test.js","sourceRoot":"","sources":["../../../src/test/unit/modelManagement.test.ts"],"names":[],"mappings":"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","sourcesContent":["import '@fgv/ts-utils-jest';\nimport { type IOllamaClient, listModels, listRunning, showModel, deleteModel } from '../../index';\nimport { psResponse, showResponse, showResponseMinimal, tagsResponse } from './fixtures/wireFixtures';\n\n/**\n * Builds a structural client mock exposing only the methods under test, typed back to\n * {@link IOllamaClient}. This is the layer-1 seam from the design (§7) — it verifies the wire→fgv\n * mapping and Result success/failure paths without HTTP or the `ollama` lib internals.\n */\nfunction mockClient(methods: Partial<Record<'list' | 'ps' | 'show' | 'delete', unknown>>): IOllamaClient {\n return methods as unknown as IOllamaClient;\n}\n\ndescribe('listModels', () => {\n test('normalizes /api/tags entries to camelCase with a parsed modifiedAt Date', async () => {\n const client = mockClient({ list: jest.fn().mockResolvedValue(tagsResponse) });\n expect(await listModels(client)).toSucceedAndSatisfy((models) => {\n expect(models).toHaveLength(2);\n const [full, sparse] = models;\n expect(full.name).toBe('llama3.1:8b');\n expect(full.size).toBe(4661226402);\n expect(full.digest).toBe('sha256:abc123');\n expect(full.modifiedAt).toBeInstanceOf(Date);\n expect(full.modifiedAt.toISOString()).toBe('2024-08-01T12:34:56.789Z');\n expect(full.details).toEqual({\n parentModel: '',\n format: 'gguf',\n family: 'llama',\n families: ['llama'],\n parameterSize: '8.0B',\n quantizationLevel: 'Q4_0'\n });\n // An empty `parent_model` is preserved as '' (present-and-empty, distinct from undefined).\n expect(full.details.parentModel).toBe('');\n // Sparse details: omitted wire fields normalize to undefined.\n expect(sparse.details.format).toBe('gguf');\n expect(sparse.details.parameterSize).toBeUndefined();\n expect(sparse.details.quantizationLevel).toBeUndefined();\n expect(sparse.details.parentModel).toBeUndefined();\n expect(sparse.details.families).toBeUndefined();\n });\n });\n\n test('fails when the client throws', async () => {\n const client = mockClient({ list: jest.fn().mockRejectedValue(new Error('connection refused')) });\n expect(await listModels(client)).toFailWith(/connection refused/);\n });\n});\n\ndescribe('listRunning', () => {\n test('normalizes /api/ps entries with expiresAt Date and sizeVram', async () => {\n const client = mockClient({ ps: jest.fn().mockResolvedValue(psResponse) });\n expect(await listRunning(client)).toSucceedAndSatisfy((running) => {\n expect(running).toHaveLength(1);\n const [model] = running;\n expect(model.name).toBe('llama3.1:8b');\n expect(model.sizeVram).toBe(4661226402);\n expect(model.expiresAt).toBeInstanceOf(Date);\n expect(model.expiresAt.toISOString()).toBe('2024-08-01T13:00:00.000Z');\n expect(model.details.family).toBe('llama');\n });\n });\n\n test('fails when the client throws', async () => {\n const client = mockClient({ ps: jest.fn().mockRejectedValue(new Error('daemon down')) });\n expect(await listRunning(client)).toFailWith(/daemon down/);\n });\n});\n\ndescribe('showModel', () => {\n test('normalizes /api/show including the model_info metadata bag', async () => {\n const show = jest.fn().mockResolvedValue(showResponse);\n const client = mockClient({ show });\n expect(await showModel(client, 'llama3.1:8b')).toSucceedAndSatisfy((info) => {\n expect(info.modelfile).toBe('FROM llama3.1:8b');\n expect(info.parameters).toBe('stop \"<|eot_id|>\"');\n expect(info.template).toBe('{{ .Prompt }}');\n expect(info.capabilities).toEqual(['completion', 'tools']);\n expect(info.modelInfo).toEqual({\n 'general.architecture': 'llama',\n 'llama.context_length': 131072,\n 'llama.embedding_length': 4096\n });\n expect(info.details.parameterSize).toBe('8.0B');\n });\n // No verbose flag was requested, so only `model` is on the request body.\n expect(show).toHaveBeenCalledWith({ model: 'llama3.1:8b' });\n });\n\n test('maps an absent model_info to undefined', async () => {\n const client = mockClient({ show: jest.fn().mockResolvedValue(showResponseMinimal) });\n expect(await showModel(client, 'custom:latest')).toSucceedAndSatisfy((info) => {\n expect(info.modelInfo).toBeUndefined();\n expect(info.capabilities).toBeUndefined();\n expect(info.modelfile).toBeUndefined();\n expect(info.details.family).toBe('llama');\n });\n });\n\n test('forwards verbose: true on the /api/show request body', async () => {\n const show = jest.fn().mockResolvedValue(showResponse);\n const client = mockClient({ show });\n expect(await showModel(client, 'llama3.1:8b', { verbose: true })).toSucceed();\n expect(show).toHaveBeenCalledWith({ model: 'llama3.1:8b', verbose: true });\n });\n\n test('fails when the client throws (unknown model)', async () => {\n const client = mockClient({ show: jest.fn().mockRejectedValue(new Error('model not found')) });\n expect(await showModel(client, 'nope:latest')).toFailWith(/model not found/);\n });\n});\n\ndescribe('deleteModel', () => {\n test('succeeds with a meaningful result and forwards the model name', async () => {\n const del = jest.fn().mockResolvedValue({ status: 'success' });\n const client = mockClient({ delete: del });\n expect(await deleteModel(client, 'llama3.1:8b')).toSucceedWith({\n model: 'llama3.1:8b',\n deleted: true\n });\n expect(del).toHaveBeenCalledWith({ model: 'llama3.1:8b' });\n });\n\n test('fails when the client throws (missing model)', async () => {\n const client = mockClient({ delete: jest.fn().mockRejectedValue(new Error('model not found')) });\n expect(await deleteModel(client, 'nope:latest')).toFailWith(/model not found/);\n });\n});\n"]}
@@ -0,0 +1,38 @@
1
+ import '@fgv/ts-utils-jest';
2
+ import { Ollama } from 'ollama';
3
+ import { createOllamaClient } from '../../index';
4
+ /**
5
+ * Reads the upstream client's (protected) resolved host for assertion. The `Ollama` instance
6
+ * keeps its config on a protected field; tests reach it through a narrow cast rather than widening
7
+ * the production surface.
8
+ */
9
+ function resolvedHost(client) {
10
+ return client.config.host;
11
+ }
12
+ describe('createOllamaClient', () => {
13
+ test('succeeds with default host when no params are supplied', () => {
14
+ expect(createOllamaClient()).toSucceedAndSatisfy((client) => {
15
+ expect(client).toBeInstanceOf(Ollama);
16
+ expect(resolvedHost(client)).toBe('http://127.0.0.1:11434');
17
+ });
18
+ });
19
+ test('applies a custom host', () => {
20
+ expect(createOllamaClient({ host: 'http://10.0.0.5:11434' })).toSucceedAndSatisfy((client) => {
21
+ expect(resolvedHost(client)).toBe('http://10.0.0.5:11434');
22
+ });
23
+ });
24
+ test('accepts a custom fetch and headers', () => {
25
+ const customFetch = jest.fn();
26
+ expect(createOllamaClient({
27
+ host: 'http://localhost:11434',
28
+ fetch: customFetch,
29
+ headers: { Authorization: 'Bearer token' }
30
+ })).toSucceedAndSatisfy((client) => {
31
+ expect(client).toBeInstanceOf(Ollama);
32
+ });
33
+ });
34
+ test('fails when the host is malformed (upstream constructor throws)', () => {
35
+ expect(createOllamaClient({ host: 'not a url' })).toFailWith(/invalid url/i);
36
+ });
37
+ });
38
+ //# sourceMappingURL=ollamaClient.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ollamaClient.test.js","sourceRoot":"","sources":["../../../src/test/unit/ollamaClient.test.ts"],"names":[],"mappings":"AAAA,OAAO,oBAAoB,CAAC;AAC5B,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAChC,OAAO,EAAE,kBAAkB,EAAE,MAAM,aAAa,CAAC;AAEjD;;;;GAIG;AACH,SAAS,YAAY,CAAC,MAAe;IACnC,OAAQ,MAAuC,CAAC,MAAM,CAAC,IAAI,CAAC;AAC9D,CAAC;AAED,QAAQ,CAAC,oBAAoB,EAAE,GAAG,EAAE;IAClC,IAAI,CAAC,wDAAwD,EAAE,GAAG,EAAE;QAClE,MAAM,CAAC,kBAAkB,EAAE,CAAC,CAAC,mBAAmB,CAAC,CAAC,MAAM,EAAE,EAAE;YAC1D,MAAM,CAAC,MAAM,CAAC,CAAC,cAAc,CAAC,MAAM,CAAC,CAAC;YACtC,MAAM,CAAC,YAAY,CAAC,MAAM,CAAC,CAAC,CAAC,IAAI,CAAC,wBAAwB,CAAC,CAAC;QAC9D,CAAC,CAAC,CAAC;IACL,CAAC,CAAC,CAAC;IAEH,IAAI,CAAC,uBAAuB,EAAE,GAAG,EAAE;QACjC,MAAM,CAAC,kBAAkB,CAAC,EAAE,IAAI,EAAE,uBAAuB,EAAE,CAAC,CAAC,CAAC,mBAAmB,CAAC,CAAC,MAAM,EAAE,EAAE;YAC3F,MAAM,CAAC,YAAY,CAAC,MAAM,CAAC,CAAC,CAAC,IAAI,CAAC,uBAAuB,CAAC,CAAC;QAC7D,CAAC,CAAC,CAAC;IACL,CAAC,CAAC,CAAC;IAEH,IAAI,CAAC,oCAAoC,EAAE,GAAG,EAAE;QAC9C,MAAM,WAAW,GAAG,IAAI,CAAC,EAAE,EAA6B,CAAC;QACzD,MAAM,CACJ,kBAAkB,CAAC;YACjB,IAAI,EAAE,wBAAwB;YAC9B,KAAK,EAAE,WAAW;YAClB,OAAO,EAAE,EAAE,aAAa,EAAE,cAAc,EAAE;SAC3C,CAAC,CACH,CAAC,mBAAmB,CAAC,CAAC,MAAM,EAAE,EAAE;YAC/B,MAAM,CAAC,MAAM,CAAC,CAAC,cAAc,CAAC,MAAM,CAAC,CAAC;QACxC,CAAC,CAAC,CAAC;IACL,CAAC,CAAC,CAAC;IAEH,IAAI,CAAC,gEAAgE,EAAE,GAAG,EAAE;QAC1E,MAAM,CAAC,kBAAkB,CAAC,EAAE,IAAI,EAAE,WAAW,EAAE,CAAC,CAAC,CAAC,UAAU,CAAC,cAAc,CAAC,CAAC;IAC/E,CAAC,CAAC,CAAC;AACL,CAAC,CAAC,CAAC","sourcesContent":["import '@fgv/ts-utils-jest';\nimport { Ollama } from 'ollama';\nimport { createOllamaClient } from '../../index';\n\n/**\n * Reads the upstream client's (protected) resolved host for assertion. The `Ollama` instance\n * keeps its config on a protected field; tests reach it through a narrow cast rather than widening\n * the production surface.\n */\nfunction resolvedHost(client: unknown): string {\n return (client as { config: { host: string } }).config.host;\n}\n\ndescribe('createOllamaClient', () => {\n test('succeeds with default host when no params are supplied', () => {\n expect(createOllamaClient()).toSucceedAndSatisfy((client) => {\n expect(client).toBeInstanceOf(Ollama);\n expect(resolvedHost(client)).toBe('http://127.0.0.1:11434');\n });\n });\n\n test('applies a custom host', () => {\n expect(createOllamaClient({ host: 'http://10.0.0.5:11434' })).toSucceedAndSatisfy((client) => {\n expect(resolvedHost(client)).toBe('http://10.0.0.5:11434');\n });\n });\n\n test('accepts a custom fetch and headers', () => {\n const customFetch = jest.fn() as unknown as typeof fetch;\n expect(\n createOllamaClient({\n host: 'http://localhost:11434',\n fetch: customFetch,\n headers: { Authorization: 'Bearer token' }\n })\n ).toSucceedAndSatisfy((client) => {\n expect(client).toBeInstanceOf(Ollama);\n });\n });\n\n test('fails when the host is malformed (upstream constructor throws)', () => {\n expect(createOllamaClient({ host: 'not a url' })).toFailWith(/invalid url/i);\n });\n});\n"]}