@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.
- package/.rush/temp/b70e5f6b6ada97ea70c5583027ea2b58b27bef46.tar.log +54 -0
- package/.rush/temp/chunked-rush-logs/ts-extras-ollama.build.chunks.jsonl +9 -0
- package/.rush/temp/operation/build/all.log +9 -0
- package/.rush/temp/operation/build/log-chunks.jsonl +9 -0
- package/.rush/temp/operation/build/state.json +3 -0
- package/.rush/temp/shrinkwrap-deps.json +681 -0
- package/CHANGELOG.json +4 -0
- package/README.md +105 -0
- package/config/api-extractor.json +38 -0
- package/config/jest.config.json +13 -0
- package/config/rig.json +6 -0
- package/dist/index.js +388 -0
- package/dist/index.js.map +1 -0
- package/dist/test/unit/chatStructured.test.js +287 -0
- package/dist/test/unit/chatStructured.test.js.map +1 -0
- package/dist/test/unit/fixtures/wireFixtures.js +90 -0
- package/dist/test/unit/fixtures/wireFixtures.js.map +1 -0
- package/dist/test/unit/modelManagement.test.js +118 -0
- package/dist/test/unit/modelManagement.test.js.map +1 -0
- package/dist/test/unit/ollamaClient.test.js +38 -0
- package/dist/test/unit/ollamaClient.test.js.map +1 -0
- package/dist/test/unit/pullModel.test.js +202 -0
- package/dist/test/unit/pullModel.test.js.map +1 -0
- package/dist/ts-extras-ollama.d.ts +365 -0
- package/dist/tsdoc-metadata.json +11 -0
- package/eslint.config.js +15 -0
- package/etc/ts-extras-ollama.api.md +139 -0
- package/lib/index.d.ts +341 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +397 -0
- package/lib/index.js.map +1 -0
- package/lib/test/unit/chatStructured.test.d.ts +2 -0
- package/lib/test/unit/chatStructured.test.d.ts.map +1 -0
- package/lib/test/unit/chatStructured.test.js +289 -0
- package/lib/test/unit/chatStructured.test.js.map +1 -0
- package/lib/test/unit/fixtures/wireFixtures.d.ts +19 -0
- package/lib/test/unit/fixtures/wireFixtures.d.ts.map +1 -0
- package/lib/test/unit/fixtures/wireFixtures.js +93 -0
- package/lib/test/unit/fixtures/wireFixtures.js.map +1 -0
- package/lib/test/unit/modelManagement.test.d.ts +2 -0
- package/lib/test/unit/modelManagement.test.d.ts.map +1 -0
- package/lib/test/unit/modelManagement.test.js +120 -0
- package/lib/test/unit/modelManagement.test.js.map +1 -0
- package/lib/test/unit/ollamaClient.test.d.ts +2 -0
- package/lib/test/unit/ollamaClient.test.d.ts.map +1 -0
- package/lib/test/unit/ollamaClient.test.js +40 -0
- package/lib/test/unit/ollamaClient.test.js.map +1 -0
- package/lib/test/unit/pullModel.test.d.ts +2 -0
- package/lib/test/unit/pullModel.test.d.ts.map +1 -0
- package/lib/test/unit/pullModel.test.js +204 -0
- package/lib/test/unit/pullModel.test.js.map +1 -0
- package/package.json +83 -0
- package/rush-logs/ts-extras-ollama.build.cache.log +3 -0
- package/rush-logs/ts-extras-ollama.build.log +9 -0
- package/src/index.ts +655 -0
- package/src/test/unit/chatStructured.test.ts +330 -0
- package/src/test/unit/fixtures/wireFixtures.ts +95 -0
- package/src/test/unit/modelManagement.test.ts +128 -0
- package/src/test/unit/ollamaClient.test.ts +44 -0
- package/src/test/unit/pullModel.test.ts +220 -0
- package/temp/build/lint/_eslint-5eVG3S6w.json +30 -0
- package/temp/build/typescript/ts_8nwakTlr.json +1 -0
- package/temp/ts-extras-ollama.api.json +2528 -0
- package/temp/ts-extras-ollama.api.md +139 -0
- package/tsconfig.json +8 -0
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __await = (this && this.__await) || function (v) { return this instanceof __await ? (this.v = v, this) : new __await(v); }
|
|
3
|
+
var __asyncGenerator = (this && this.__asyncGenerator) || function (thisArg, _arguments, generator) {
|
|
4
|
+
if (!Symbol.asyncIterator) throw new TypeError("Symbol.asyncIterator is not defined.");
|
|
5
|
+
var g = generator.apply(thisArg, _arguments || []), i, q = [];
|
|
6
|
+
return i = Object.create((typeof AsyncIterator === "function" ? AsyncIterator : Object).prototype), verb("next"), verb("throw"), verb("return", awaitReturn), i[Symbol.asyncIterator] = function () { return this; }, i;
|
|
7
|
+
function awaitReturn(f) { return function (v) { return Promise.resolve(v).then(f, reject); }; }
|
|
8
|
+
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]); } }
|
|
9
|
+
function resume(n, v) { try { step(g[n](v)); } catch (e) { settle(q[0][3], e); } }
|
|
10
|
+
function step(r) { r.value instanceof __await ? Promise.resolve(r.value.v).then(fulfill, reject) : settle(q[0][2], r); }
|
|
11
|
+
function fulfill(value) { resume("next", value); }
|
|
12
|
+
function reject(value) { resume("throw", value); }
|
|
13
|
+
function settle(f, v) { if (f(v), q.shift(), q.length) resume(q[0][0], q[0][1]); }
|
|
14
|
+
};
|
|
15
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
16
|
+
require("@fgv/ts-utils-jest");
|
|
17
|
+
const ts_utils_1 = require("@fgv/ts-utils");
|
|
18
|
+
const ts_json_base_1 = require("@fgv/ts-json-base");
|
|
19
|
+
const index_1 = require("../../index");
|
|
20
|
+
const personSchema = ts_json_base_1.JsonSchema.object({
|
|
21
|
+
name: ts_json_base_1.JsonSchema.string(),
|
|
22
|
+
age: ts_json_base_1.JsonSchema.integer()
|
|
23
|
+
});
|
|
24
|
+
/**
|
|
25
|
+
* Builds a structural mock of the upstream `AbortableAsyncIterator<ChatResponse>` returned by
|
|
26
|
+
* `client.chat({ stream: true })`. `abort()` flips a flag the generator checks before each chunk.
|
|
27
|
+
*/
|
|
28
|
+
function makeChatIterator(chunks) {
|
|
29
|
+
let aborted = false;
|
|
30
|
+
return {
|
|
31
|
+
abort: jest.fn(() => {
|
|
32
|
+
aborted = true;
|
|
33
|
+
}),
|
|
34
|
+
[Symbol.asyncIterator]() {
|
|
35
|
+
return __asyncGenerator(this, arguments, function* _a() {
|
|
36
|
+
for (const chunk of chunks) {
|
|
37
|
+
if (aborted) {
|
|
38
|
+
throw new Error('The operation was aborted');
|
|
39
|
+
}
|
|
40
|
+
yield yield __await(chunk);
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Like {@link makeChatIterator}, but yields control (a real timer) after each chunk so a signal
|
|
48
|
+
* aborted *after* the listener is attached lands mid-stream — exercising the abort-listener path
|
|
49
|
+
* (vs. the already-aborted branch).
|
|
50
|
+
*/
|
|
51
|
+
function makeDelayedChatIterator(chunks) {
|
|
52
|
+
let aborted = false;
|
|
53
|
+
return {
|
|
54
|
+
abort: jest.fn(() => {
|
|
55
|
+
aborted = true;
|
|
56
|
+
}),
|
|
57
|
+
[Symbol.asyncIterator]() {
|
|
58
|
+
return __asyncGenerator(this, arguments, function* _a() {
|
|
59
|
+
for (const chunk of chunks) {
|
|
60
|
+
if (aborted) {
|
|
61
|
+
throw new Error('The operation was aborted');
|
|
62
|
+
}
|
|
63
|
+
yield yield __await(chunk);
|
|
64
|
+
yield __await(new Promise((resolve) => setTimeout(resolve, 30)));
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
function mockChatClient(chat) {
|
|
71
|
+
return { chat };
|
|
72
|
+
}
|
|
73
|
+
/** Chunks whose assembled `message.content` is `{"name":"ada","age":42}`. */
|
|
74
|
+
function validPersonChunks(model = 'llama3.1:8b') {
|
|
75
|
+
return [
|
|
76
|
+
{ model, message: { role: 'assistant', content: '{"name":"ada",' }, done: false },
|
|
77
|
+
{ model, message: { role: 'assistant', content: '"age":42}' }, done: false },
|
|
78
|
+
{ model, message: { role: 'assistant', content: '' }, done: true, done_reason: 'stop' }
|
|
79
|
+
];
|
|
80
|
+
}
|
|
81
|
+
const userTurn = [{ role: 'user', content: 'Describe Ada.' }];
|
|
82
|
+
describe('chatStructured (layer 1 — structural client mock)', () => {
|
|
83
|
+
test('validates the assembled document against the same schema sent on the wire', async () => {
|
|
84
|
+
const chat = jest.fn().mockResolvedValue(makeChatIterator(validPersonChunks()));
|
|
85
|
+
const client = mockChatClient(chat);
|
|
86
|
+
expect(await (0, index_1.chatStructured)(client, { model: 'llama3.1:8b', messages: userTurn, schema: personSchema })).toSucceedAndSatisfy((result) => {
|
|
87
|
+
const value = result.value;
|
|
88
|
+
expect(value).toEqual({ name: 'ada', age: 42 });
|
|
89
|
+
expect(result.raw).toBe('{"name":"ada","age":42}');
|
|
90
|
+
expect(result.model).toBe('llama3.1:8b');
|
|
91
|
+
expect(result.doneReason).toBe('stop');
|
|
92
|
+
});
|
|
93
|
+
// The wire request: streaming, messages mapped, and the sanitized schema as `format`.
|
|
94
|
+
const request = chat.mock.calls[0][0];
|
|
95
|
+
expect(request.stream).toBe(true);
|
|
96
|
+
expect(request.messages).toEqual([{ role: 'user', content: 'Describe Ada.' }]);
|
|
97
|
+
const format = request.format;
|
|
98
|
+
expect(format.type).toBe('object');
|
|
99
|
+
expect(format.properties).toEqual({ name: { type: 'string' }, age: { type: 'integer' } });
|
|
100
|
+
expect(format.required).toEqual(['name', 'age']);
|
|
101
|
+
// Draft-07-only keywords are stripped before hitting Ollama's `format`.
|
|
102
|
+
expect(format).not.toHaveProperty('additionalProperties');
|
|
103
|
+
expect(format).not.toHaveProperty('$schema');
|
|
104
|
+
// No options / keep_alive were supplied.
|
|
105
|
+
expect(request).not.toHaveProperty('options');
|
|
106
|
+
expect(request).not.toHaveProperty('keep_alive');
|
|
107
|
+
});
|
|
108
|
+
test('forwards options, keep_alive, and base64 images verbatim', async () => {
|
|
109
|
+
const chat = jest.fn().mockResolvedValue(makeChatIterator(validPersonChunks()));
|
|
110
|
+
const client = mockChatClient(chat);
|
|
111
|
+
const messages = [
|
|
112
|
+
{ role: 'system', content: 'Be terse.' },
|
|
113
|
+
{ role: 'user', content: 'Look', images: ['aGVsbG8='] }
|
|
114
|
+
];
|
|
115
|
+
expect(await (0, index_1.chatStructured)(client, {
|
|
116
|
+
model: 'llama3.1:8b',
|
|
117
|
+
messages,
|
|
118
|
+
schema: personSchema,
|
|
119
|
+
options: { temperature: 0, seed: 7 },
|
|
120
|
+
keepAlive: '5m'
|
|
121
|
+
})).toSucceed();
|
|
122
|
+
const request = chat.mock.calls[0][0];
|
|
123
|
+
expect(request.options).toEqual({ temperature: 0, seed: 7 });
|
|
124
|
+
expect(request.keep_alive).toBe('5m');
|
|
125
|
+
expect(request.messages).toEqual([
|
|
126
|
+
{ role: 'system', content: 'Be terse.' },
|
|
127
|
+
{ role: 'user', content: 'Look', images: ['aGVsbG8='] }
|
|
128
|
+
]);
|
|
129
|
+
});
|
|
130
|
+
test('fails with context when the response parses but violates the schema', async () => {
|
|
131
|
+
const chunks = [
|
|
132
|
+
{
|
|
133
|
+
model: 'llama3.1:8b',
|
|
134
|
+
message: { role: 'assistant', content: '{"name":"ada","age":"old"}' },
|
|
135
|
+
done: true
|
|
136
|
+
}
|
|
137
|
+
];
|
|
138
|
+
const client = mockChatClient(jest.fn().mockResolvedValue(makeChatIterator(chunks)));
|
|
139
|
+
expect(await (0, index_1.chatStructured)(client, { model: 'llama3.1:8b', messages: userTurn, schema: personSchema })).toFailWith(/chatStructured\(llama3\.1:8b\): response failed schema validation/i);
|
|
140
|
+
});
|
|
141
|
+
test('fails with context when the response is not valid JSON', async () => {
|
|
142
|
+
const chunks = [
|
|
143
|
+
{ model: 'llama3.1:8b', message: { role: 'assistant', content: 'not json at all' }, done: true }
|
|
144
|
+
];
|
|
145
|
+
const client = mockChatClient(jest.fn().mockResolvedValue(makeChatIterator(chunks)));
|
|
146
|
+
expect(await (0, index_1.chatStructured)(client, { model: 'llama3.1:8b', messages: userTurn, schema: personSchema })).toFailWith(/chatStructured\(llama3\.1:8b\): response was not valid JSON/i);
|
|
147
|
+
});
|
|
148
|
+
test('an already-aborted signal cancels the generation', async () => {
|
|
149
|
+
const iterator = makeChatIterator(validPersonChunks());
|
|
150
|
+
const client = mockChatClient(jest.fn().mockResolvedValue(iterator));
|
|
151
|
+
const controller = new AbortController();
|
|
152
|
+
controller.abort();
|
|
153
|
+
expect(await (0, index_1.chatStructured)(client, {
|
|
154
|
+
model: 'llama3.1:8b',
|
|
155
|
+
messages: userTurn,
|
|
156
|
+
schema: personSchema,
|
|
157
|
+
signal: controller.signal
|
|
158
|
+
})).toFailWith(/abort/i);
|
|
159
|
+
expect(iterator.abort).toHaveBeenCalledTimes(1);
|
|
160
|
+
});
|
|
161
|
+
test('a mid-stream abort cancels the generation and removes the listener', async () => {
|
|
162
|
+
const iterator = makeDelayedChatIterator(validPersonChunks());
|
|
163
|
+
const client = mockChatClient(jest.fn().mockResolvedValue(iterator));
|
|
164
|
+
const controller = new AbortController();
|
|
165
|
+
const removeSpy = jest.spyOn(controller.signal, 'removeEventListener');
|
|
166
|
+
const promise = (0, index_1.chatStructured)(client, {
|
|
167
|
+
model: 'llama3.1:8b',
|
|
168
|
+
messages: userTurn,
|
|
169
|
+
schema: personSchema,
|
|
170
|
+
signal: controller.signal
|
|
171
|
+
});
|
|
172
|
+
// Let the request settle and the listener attach + first chunk land, then abort mid-stream so
|
|
173
|
+
// the abort fires through the listener (not the already-aborted branch).
|
|
174
|
+
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
175
|
+
controller.abort();
|
|
176
|
+
expect(await promise).toFailWith(/abort/i);
|
|
177
|
+
expect(iterator.abort).toHaveBeenCalled();
|
|
178
|
+
expect(removeSpy).toHaveBeenCalledWith('abort', expect.any(Function));
|
|
179
|
+
});
|
|
180
|
+
test('removes the abort listener after a successful completion', async () => {
|
|
181
|
+
const client = mockChatClient(jest.fn().mockResolvedValue(makeChatIterator(validPersonChunks())));
|
|
182
|
+
const controller = new AbortController();
|
|
183
|
+
const removeSpy = jest.spyOn(controller.signal, 'removeEventListener');
|
|
184
|
+
expect(await (0, index_1.chatStructured)(client, {
|
|
185
|
+
model: 'llama3.1:8b',
|
|
186
|
+
messages: userTurn,
|
|
187
|
+
schema: personSchema,
|
|
188
|
+
signal: controller.signal
|
|
189
|
+
})).toSucceed();
|
|
190
|
+
expect(removeSpy).toHaveBeenCalledWith('abort', expect.any(Function));
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
describe('chatStructured — draft-07 format sanitizer', () => {
|
|
194
|
+
test('recursively strips $schema and additionalProperties while preserving property names and arrays', async () => {
|
|
195
|
+
// A schema double whose toJson() carries $schema, nested additionalProperties, an array, and a
|
|
196
|
+
// property *named* additionalProperties (which must survive — it is a name, not a keyword).
|
|
197
|
+
const rawSchema = {
|
|
198
|
+
$schema: 'http://json-schema.org/draft-07/schema#',
|
|
199
|
+
type: 'object',
|
|
200
|
+
properties: {
|
|
201
|
+
nested: {
|
|
202
|
+
type: 'object',
|
|
203
|
+
properties: { x: { type: 'string' } },
|
|
204
|
+
additionalProperties: false
|
|
205
|
+
},
|
|
206
|
+
additionalProperties: { type: 'string' }
|
|
207
|
+
},
|
|
208
|
+
required: ['nested'],
|
|
209
|
+
additionalProperties: false
|
|
210
|
+
};
|
|
211
|
+
const schemaDouble = {
|
|
212
|
+
_type: 'object',
|
|
213
|
+
toJson: () => rawSchema,
|
|
214
|
+
validate: (value) => (0, ts_utils_1.succeed)(value)
|
|
215
|
+
};
|
|
216
|
+
const chat = jest
|
|
217
|
+
.fn()
|
|
218
|
+
.mockResolvedValue(makeChatIterator([
|
|
219
|
+
{ model: 'm', message: { role: 'assistant', content: '{"nested":{"x":"y"}}' }, done: true }
|
|
220
|
+
]));
|
|
221
|
+
const client = mockChatClient(chat);
|
|
222
|
+
expect(await (0, index_1.chatStructured)(client, { model: 'm', messages: userTurn, schema: schemaDouble })).toSucceed();
|
|
223
|
+
const format = chat.mock.calls[0][0].format;
|
|
224
|
+
expect(format).not.toHaveProperty('$schema');
|
|
225
|
+
expect(format).not.toHaveProperty('additionalProperties');
|
|
226
|
+
expect(format.required).toEqual(['nested']);
|
|
227
|
+
const properties = format.properties;
|
|
228
|
+
// The nested object's additionalProperties is stripped too.
|
|
229
|
+
expect(properties.nested).toEqual({ type: 'object', properties: { x: { type: 'string' } } });
|
|
230
|
+
// A property literally named `additionalProperties` is preserved (name, not keyword).
|
|
231
|
+
expect(properties.additionalProperties).toEqual({ type: 'string' });
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
/**
|
|
235
|
+
* Layer 2 — a real client over a mock `fetch` that streams a newline-delimited `/api/chat`
|
|
236
|
+
* response. Proves the no-drift claim end-to-end: the request body that actually goes on the wire
|
|
237
|
+
* carries `format` = the sanitized `schema.toJson()`, and the assembled reply validates.
|
|
238
|
+
*/
|
|
239
|
+
function ndjsonResponse(lines) {
|
|
240
|
+
const bytes = new TextEncoder().encode(lines.join('\n') + '\n');
|
|
241
|
+
const mid = Math.floor(bytes.length / 2);
|
|
242
|
+
const parts = [bytes.slice(0, mid), bytes.slice(mid)];
|
|
243
|
+
let i = 0;
|
|
244
|
+
const stream = new ReadableStream({
|
|
245
|
+
pull(controller) {
|
|
246
|
+
if (i < parts.length) {
|
|
247
|
+
controller.enqueue(parts[i++]);
|
|
248
|
+
}
|
|
249
|
+
else {
|
|
250
|
+
controller.close();
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
return new Response(stream, { status: 200 });
|
|
255
|
+
}
|
|
256
|
+
describe('chatStructured (layer 2 — fetch-level wire-format integration)', () => {
|
|
257
|
+
test('sends the sanitized schema as the wire format and validates the assembled reply', async () => {
|
|
258
|
+
const lines = [
|
|
259
|
+
JSON.stringify({
|
|
260
|
+
model: 'llama3.1:8b',
|
|
261
|
+
message: { role: 'assistant', content: '{"name":"ada",' },
|
|
262
|
+
done: false
|
|
263
|
+
}),
|
|
264
|
+
JSON.stringify({
|
|
265
|
+
model: 'llama3.1:8b',
|
|
266
|
+
message: { role: 'assistant', content: '"age":42}' },
|
|
267
|
+
done: true,
|
|
268
|
+
done_reason: 'stop'
|
|
269
|
+
})
|
|
270
|
+
];
|
|
271
|
+
const fetchMock = jest.fn().mockResolvedValue(ndjsonResponse(lines));
|
|
272
|
+
const client = (0, index_1.createOllamaClient)({ fetch: fetchMock }).orThrow();
|
|
273
|
+
expect(await (0, index_1.chatStructured)(client, { model: 'llama3.1:8b', messages: userTurn, schema: personSchema })).toSucceedAndSatisfy((result) => {
|
|
274
|
+
expect(result.value).toEqual({ name: 'ada', age: 42 });
|
|
275
|
+
expect(result.raw).toBe('{"name":"ada","age":42}');
|
|
276
|
+
expect(result.doneReason).toBe('stop');
|
|
277
|
+
});
|
|
278
|
+
// The actual wire body carries the sanitized schema as `format` — no-drift proven end-to-end.
|
|
279
|
+
const requestInit = fetchMock.mock.calls[0][1];
|
|
280
|
+
const body = JSON.parse(requestInit.body);
|
|
281
|
+
expect(body.stream).toBe(true);
|
|
282
|
+
const format = body.format;
|
|
283
|
+
expect(format.type).toBe('object');
|
|
284
|
+
expect(format.properties).toEqual({ name: { type: 'string' }, age: { type: 'integer' } });
|
|
285
|
+
expect(format).not.toHaveProperty('additionalProperties');
|
|
286
|
+
expect(format).not.toHaveProperty('$schema');
|
|
287
|
+
});
|
|
288
|
+
});
|
|
289
|
+
//# 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,19 @@
|
|
|
1
|
+
import { type ListResponse, type ShowResponse } from 'ollama';
|
|
2
|
+
/**
|
|
3
|
+
* Canned wire-shape fixtures for the layer-1 structural-client-mock tests.
|
|
4
|
+
*
|
|
5
|
+
* These mirror the actual JSON the Ollama daemon returns — snake_case keys and **string**
|
|
6
|
+
* timestamps. The upstream `ollama` types annotate `modified_at` / `expires_at` as `Date`, but the
|
|
7
|
+
* library returns `response.json()` verbatim so the wire delivers RFC3339 strings; each fixture is
|
|
8
|
+
* cast once (`as unknown as <Response>`) to reconcile the inaccurate upstream annotation with the
|
|
9
|
+
* real shape, so the normalization layer is exercised against the data it actually sees.
|
|
10
|
+
*/
|
|
11
|
+
/** `GET /api/tags` — one fully-detailed model plus one with sparse `details` (missing fields). */
|
|
12
|
+
export declare const tagsResponse: ListResponse;
|
|
13
|
+
/** `GET /api/ps` — a running model with `expires_at` + `size_vram` (no `modified_at`). */
|
|
14
|
+
export declare const psResponse: ListResponse;
|
|
15
|
+
/** `POST /api/show` — full response including the low-level `model_info` metadata bag. */
|
|
16
|
+
export declare const showResponse: ShowResponse;
|
|
17
|
+
/** `POST /api/show` — a minimal response with `model_info` and `capabilities` omitted. */
|
|
18
|
+
export declare const showResponseMinimal: ShowResponse;
|
|
19
|
+
//# sourceMappingURL=wireFixtures.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"wireFixtures.d.ts","sourceRoot":"","sources":["../../../../src/test/unit/fixtures/wireFixtures.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,YAAY,EAAE,KAAK,YAAY,EAAE,MAAM,QAAQ,CAAC;AAE9D;;;;;;;;GAQG;AAEH,kGAAkG;AAClG,eAAO,MAAM,YAAY,EAAE,YA8BC,CAAC;AAE7B,0FAA0F;AAC1F,eAAO,MAAM,UAAU,EAAE,YAmBG,CAAC;AAE7B,0FAA0F;AAC1F,eAAO,MAAM,YAAY,EAAE,YAkBC,CAAC;AAE7B,0FAA0F;AAC1F,eAAO,MAAM,mBAAmB,EAAE,YAKN,CAAC"}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.showResponseMinimal = exports.showResponse = exports.psResponse = exports.tagsResponse = void 0;
|
|
4
|
+
/**
|
|
5
|
+
* Canned wire-shape fixtures for the layer-1 structural-client-mock tests.
|
|
6
|
+
*
|
|
7
|
+
* These mirror the actual JSON the Ollama daemon returns — snake_case keys and **string**
|
|
8
|
+
* timestamps. The upstream `ollama` types annotate `modified_at` / `expires_at` as `Date`, but the
|
|
9
|
+
* library returns `response.json()` verbatim so the wire delivers RFC3339 strings; each fixture is
|
|
10
|
+
* cast once (`as unknown as <Response>`) to reconcile the inaccurate upstream annotation with the
|
|
11
|
+
* real shape, so the normalization layer is exercised against the data it actually sees.
|
|
12
|
+
*/
|
|
13
|
+
/** `GET /api/tags` — one fully-detailed model plus one with sparse `details` (missing fields). */
|
|
14
|
+
exports.tagsResponse = {
|
|
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
|
+
};
|
|
45
|
+
/** `GET /api/ps` — a running model with `expires_at` + `size_vram` (no `modified_at`). */
|
|
46
|
+
exports.psResponse = {
|
|
47
|
+
models: [
|
|
48
|
+
{
|
|
49
|
+
name: 'llama3.1:8b',
|
|
50
|
+
model: 'llama3.1:8b',
|
|
51
|
+
size: 4661226402,
|
|
52
|
+
digest: 'sha256:abc123',
|
|
53
|
+
details: {
|
|
54
|
+
parent_model: '',
|
|
55
|
+
format: 'gguf',
|
|
56
|
+
family: 'llama',
|
|
57
|
+
families: ['llama'],
|
|
58
|
+
parameter_size: '8.0B',
|
|
59
|
+
quantization_level: 'Q4_0'
|
|
60
|
+
},
|
|
61
|
+
expires_at: '2024-08-01T13:00:00.000Z',
|
|
62
|
+
size_vram: 4661226402
|
|
63
|
+
}
|
|
64
|
+
]
|
|
65
|
+
};
|
|
66
|
+
/** `POST /api/show` — full response including the low-level `model_info` metadata bag. */
|
|
67
|
+
exports.showResponse = {
|
|
68
|
+
modelfile: 'FROM llama3.1:8b',
|
|
69
|
+
parameters: 'stop "<|eot_id|>"',
|
|
70
|
+
template: '{{ .Prompt }}',
|
|
71
|
+
details: {
|
|
72
|
+
parent_model: '',
|
|
73
|
+
format: 'gguf',
|
|
74
|
+
family: 'llama',
|
|
75
|
+
families: ['llama'],
|
|
76
|
+
parameter_size: '8.0B',
|
|
77
|
+
quantization_level: 'Q4_0'
|
|
78
|
+
},
|
|
79
|
+
model_info: {
|
|
80
|
+
'general.architecture': 'llama',
|
|
81
|
+
'llama.context_length': 131072,
|
|
82
|
+
'llama.embedding_length': 4096
|
|
83
|
+
},
|
|
84
|
+
capabilities: ['completion', 'tools']
|
|
85
|
+
};
|
|
86
|
+
/** `POST /api/show` — a minimal response with `model_info` and `capabilities` omitted. */
|
|
87
|
+
exports.showResponseMinimal = {
|
|
88
|
+
details: {
|
|
89
|
+
format: 'gguf',
|
|
90
|
+
family: 'llama'
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
//# 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;AACrF,QAAA,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;AAC7E,QAAA,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;AAC7E,QAAA,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;AAC7E,QAAA,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 @@
|
|
|
1
|
+
{"version":3,"file":"modelManagement.test.d.ts","sourceRoot":"","sources":["../../../src/test/unit/modelManagement.test.ts"],"names":[],"mappings":"AAAA,OAAO,oBAAoB,CAAC"}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
require("@fgv/ts-utils-jest");
|
|
4
|
+
const index_1 = require("../../index");
|
|
5
|
+
const wireFixtures_1 = require("./fixtures/wireFixtures");
|
|
6
|
+
/**
|
|
7
|
+
* Builds a structural client mock exposing only the methods under test, typed back to
|
|
8
|
+
* {@link IOllamaClient}. This is the layer-1 seam from the design (§7) — it verifies the wire→fgv
|
|
9
|
+
* mapping and Result success/failure paths without HTTP or the `ollama` lib internals.
|
|
10
|
+
*/
|
|
11
|
+
function mockClient(methods) {
|
|
12
|
+
return methods;
|
|
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(wireFixtures_1.tagsResponse) });
|
|
17
|
+
expect(await (0, index_1.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
|
+
test('fails when the client throws', async () => {
|
|
44
|
+
const client = mockClient({ list: jest.fn().mockRejectedValue(new Error('connection refused')) });
|
|
45
|
+
expect(await (0, index_1.listModels)(client)).toFailWith(/connection refused/);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
describe('listRunning', () => {
|
|
49
|
+
test('normalizes /api/ps entries with expiresAt Date and sizeVram', async () => {
|
|
50
|
+
const client = mockClient({ ps: jest.fn().mockResolvedValue(wireFixtures_1.psResponse) });
|
|
51
|
+
expect(await (0, index_1.listRunning)(client)).toSucceedAndSatisfy((running) => {
|
|
52
|
+
expect(running).toHaveLength(1);
|
|
53
|
+
const [model] = running;
|
|
54
|
+
expect(model.name).toBe('llama3.1:8b');
|
|
55
|
+
expect(model.sizeVram).toBe(4661226402);
|
|
56
|
+
expect(model.expiresAt).toBeInstanceOf(Date);
|
|
57
|
+
expect(model.expiresAt.toISOString()).toBe('2024-08-01T13:00:00.000Z');
|
|
58
|
+
expect(model.details.family).toBe('llama');
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
test('fails when the client throws', async () => {
|
|
62
|
+
const client = mockClient({ ps: jest.fn().mockRejectedValue(new Error('daemon down')) });
|
|
63
|
+
expect(await (0, index_1.listRunning)(client)).toFailWith(/daemon down/);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
describe('showModel', () => {
|
|
67
|
+
test('normalizes /api/show including the model_info metadata bag', async () => {
|
|
68
|
+
const show = jest.fn().mockResolvedValue(wireFixtures_1.showResponse);
|
|
69
|
+
const client = mockClient({ show });
|
|
70
|
+
expect(await (0, index_1.showModel)(client, 'llama3.1:8b')).toSucceedAndSatisfy((info) => {
|
|
71
|
+
expect(info.modelfile).toBe('FROM llama3.1:8b');
|
|
72
|
+
expect(info.parameters).toBe('stop "<|eot_id|>"');
|
|
73
|
+
expect(info.template).toBe('{{ .Prompt }}');
|
|
74
|
+
expect(info.capabilities).toEqual(['completion', 'tools']);
|
|
75
|
+
expect(info.modelInfo).toEqual({
|
|
76
|
+
'general.architecture': 'llama',
|
|
77
|
+
'llama.context_length': 131072,
|
|
78
|
+
'llama.embedding_length': 4096
|
|
79
|
+
});
|
|
80
|
+
expect(info.details.parameterSize).toBe('8.0B');
|
|
81
|
+
});
|
|
82
|
+
// No verbose flag was requested, so only `model` is on the request body.
|
|
83
|
+
expect(show).toHaveBeenCalledWith({ model: 'llama3.1:8b' });
|
|
84
|
+
});
|
|
85
|
+
test('maps an absent model_info to undefined', async () => {
|
|
86
|
+
const client = mockClient({ show: jest.fn().mockResolvedValue(wireFixtures_1.showResponseMinimal) });
|
|
87
|
+
expect(await (0, index_1.showModel)(client, 'custom:latest')).toSucceedAndSatisfy((info) => {
|
|
88
|
+
expect(info.modelInfo).toBeUndefined();
|
|
89
|
+
expect(info.capabilities).toBeUndefined();
|
|
90
|
+
expect(info.modelfile).toBeUndefined();
|
|
91
|
+
expect(info.details.family).toBe('llama');
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
test('forwards verbose: true on the /api/show request body', async () => {
|
|
95
|
+
const show = jest.fn().mockResolvedValue(wireFixtures_1.showResponse);
|
|
96
|
+
const client = mockClient({ show });
|
|
97
|
+
expect(await (0, index_1.showModel)(client, 'llama3.1:8b', { verbose: true })).toSucceed();
|
|
98
|
+
expect(show).toHaveBeenCalledWith({ model: 'llama3.1:8b', verbose: true });
|
|
99
|
+
});
|
|
100
|
+
test('fails when the client throws (unknown model)', async () => {
|
|
101
|
+
const client = mockClient({ show: jest.fn().mockRejectedValue(new Error('model not found')) });
|
|
102
|
+
expect(await (0, index_1.showModel)(client, 'nope:latest')).toFailWith(/model not found/);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
describe('deleteModel', () => {
|
|
106
|
+
test('succeeds with a meaningful result and forwards the model name', async () => {
|
|
107
|
+
const del = jest.fn().mockResolvedValue({ status: 'success' });
|
|
108
|
+
const client = mockClient({ delete: del });
|
|
109
|
+
expect(await (0, index_1.deleteModel)(client, 'llama3.1:8b')).toSucceedWith({
|
|
110
|
+
model: 'llama3.1:8b',
|
|
111
|
+
deleted: true
|
|
112
|
+
});
|
|
113
|
+
expect(del).toHaveBeenCalledWith({ model: 'llama3.1:8b' });
|
|
114
|
+
});
|
|
115
|
+
test('fails when the client throws (missing model)', async () => {
|
|
116
|
+
const client = mockClient({ delete: jest.fn().mockRejectedValue(new Error('model not found')) });
|
|
117
|
+
expect(await (0, index_1.deleteModel)(client, 'nope:latest')).toFailWith(/model not found/);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
//# 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":";;AAAA,8BAA4B;AAC5B,uCAAkG;AAClG,0DAAsG;AAEtG;;;;GAIG;AACH,SAAS,UAAU,CAAC,OAAoE;IACtF,OAAO,OAAmC,CAAC;AAC7C,CAAC;AAED,QAAQ,CAAC,YAAY,EAAE,GAAG,EAAE;IAC1B,IAAI,CAAC,yEAAyE,EAAE,KAAK,IAAI,EAAE;QACzF,MAAM,MAAM,GAAG,UAAU,CAAC,EAAE,IAAI,EAAE,IAAI,CAAC,EAAE,EAAE,CAAC,iBAAiB,CAAC,2BAAY,CAAC,EAAE,CAAC,CAAC;QAC/E,MAAM,CAAC,MAAM,IAAA,kBAAU,EAAC,MAAM,CAAC,CAAC,CAAC,mBAAmB,CAAC,CAAC,MAAM,EAAE,EAAE;YAC9D,MAAM,CAAC,MAAM,CAAC,CAAC,YAAY,CAAC,CAAC,CAAC,CAAC;YAC/B,MAAM,CAAC,IAAI,EAAE,MAAM,CAAC,GAAG,MAAM,CAAC;YAC9B,MAAM,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,IAAI,CAAC,aAAa,CAAC,CAAC;YACtC,MAAM,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,IAAI,CAAC,UAAU,CAAC,CAAC;YACnC,MAAM,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC,IAAI,CAAC,eAAe,CAAC,CAAC;YAC1C,MAAM,CAAC,IAAI,CAAC,UAAU,CAAC,CAAC,cAAc,CAAC,IAAI,CAAC,CAAC;YAC7C,MAAM,CAAC,IAAI,CAAC,UAAU,CAAC,WAAW,EAAE,CAAC,CAAC,IAAI,CAAC,0BAA0B,CAAC,CAAC;YACvE,MAAM,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC,OAAO,CAAC;gBAC3B,WAAW,EAAE,EAAE;gBACf,MAAM,EAAE,MAAM;gBACd,MAAM,EAAE,OAAO;gBACf,QAAQ,EAAE,CAAC,OAAO,CAAC;gBACnB,aAAa,EAAE,MAAM;gBACrB,iBAAiB,EAAE,MAAM;aAC1B,CAAC,CAAC;YACH,2FAA2F;YAC3F,MAAM,CAAC,IAAI,CAAC,OAAO,CAAC,WAAW,CAAC,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;YAC1C,8DAA8D;YAC9D,MAAM,CAAC,MAAM,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;YAC3C,MAAM,CAAC,MAAM,CAAC,OAAO,CAAC,aAAa,CAAC,CAAC,aAAa,EAAE,CAAC;YACrD,MAAM,CAAC,MAAM,CAAC,OAAO,CAAC,iBAAiB,CAAC,CAAC,aAAa,EAAE,CAAC;YACzD,MAAM,CAAC,MAAM,CAAC,OAAO,CAAC,WAAW,CAAC,CAAC,aAAa,EAAE,CAAC;YACnD,MAAM,CAAC,MAAM,CAAC,OAAO,CAAC,QAAQ,CAAC,CAAC,aAAa,EAAE,CAAC;QAClD,CAAC,CAAC,CAAC;IACL,CAAC,CAAC,CAAC;IAEH,IAAI,CAAC,8BAA8B,EAAE,KAAK,IAAI,EAAE;QAC9C,MAAM,MAAM,GAAG,UAAU,CAAC,EAAE,IAAI,EAAE,IAAI,CAAC,EAAE,EAAE,CAAC,iBAAiB,CAAC,IAAI,KAAK,CAAC,oBAAoB,CAAC,CAAC,EAAE,CAAC,CAAC;QAClG,MAAM,CAAC,MAAM,IAAA,kBAAU,EAAC,MAAM,CAAC,CAAC,CAAC,UAAU,CAAC,oBAAoB,CAAC,CAAC;IACpE,CAAC,CAAC,CAAC;AACL,CAAC,CAAC,CAAC;AAEH,QAAQ,CAAC,aAAa,EAAE,GAAG,EAAE;IAC3B,IAAI,CAAC,6DAA6D,EAAE,KAAK,IAAI,EAAE;QAC7E,MAAM,MAAM,GAAG,UAAU,CAAC,EAAE,EAAE,EAAE,IAAI,CAAC,EAAE,EAAE,CAAC,iBAAiB,CAAC,yBAAU,CAAC,EAAE,CAAC,CAAC;QAC3E,MAAM,CAAC,MAAM,IAAA,mBAAW,EAAC,MAAM,CAAC,CAAC,CAAC,mBAAmB,CAAC,CAAC,OAAO,EAAE,EAAE;YAChE,MAAM,CAAC,OAAO,CAAC,CAAC,YAAY,CAAC,CAAC,CAAC,CAAC;YAChC,MAAM,CAAC,KAAK,CAAC,GAAG,OAAO,CAAC;YACxB,MAAM,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,IAAI,CAAC,aAAa,CAAC,CAAC;YACvC,MAAM,CAAC,KAAK,CAAC,QAAQ,CAAC,CAAC,IAAI,CAAC,UAAU,CAAC,CAAC;YACxC,MAAM,CAAC,KAAK,CAAC,SAAS,CAAC,CAAC,cAAc,CAAC,IAAI,CAAC,CAAC;YAC7C,MAAM,CAAC,KAAK,CAAC,SAAS,CAAC,WAAW,EAAE,CAAC,CAAC,IAAI,CAAC,0BAA0B,CAAC,CAAC;YACvE,MAAM,CAAC,KAAK,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;QAC7C,CAAC,CAAC,CAAC;IACL,CAAC,CAAC,CAAC;IAEH,IAAI,CAAC,8BAA8B,EAAE,KAAK,IAAI,EAAE;QAC9C,MAAM,MAAM,GAAG,UAAU,CAAC,EAAE,EAAE,EAAE,IAAI,CAAC,EAAE,EAAE,CAAC,iBAAiB,CAAC,IAAI,KAAK,CAAC,aAAa,CAAC,CAAC,EAAE,CAAC,CAAC;QACzF,MAAM,CAAC,MAAM,IAAA,mBAAW,EAAC,MAAM,CAAC,CAAC,CAAC,UAAU,CAAC,aAAa,CAAC,CAAC;IAC9D,CAAC,CAAC,CAAC;AACL,CAAC,CAAC,CAAC;AAEH,QAAQ,CAAC,WAAW,EAAE,GAAG,EAAE;IACzB,IAAI,CAAC,4DAA4D,EAAE,KAAK,IAAI,EAAE;QAC5E,MAAM,IAAI,GAAG,IAAI,CAAC,EAAE,EAAE,CAAC,iBAAiB,CAAC,2BAAY,CAAC,CAAC;QACvD,MAAM,MAAM,GAAG,UAAU,CAAC,EAAE,IAAI,EAAE,CAAC,CAAC;QACpC,MAAM,CAAC,MAAM,IAAA,iBAAS,EAAC,MAAM,EAAE,aAAa,CAAC,CAAC,CAAC,mBAAmB,CAAC,CAAC,IAAI,EAAE,EAAE;YAC1E,MAAM,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC,IAAI,CAAC,kBAAkB,CAAC,CAAC;YAChD,MAAM,CAAC,IAAI,CAAC,UAAU,CAAC,CAAC,IAAI,CAAC,mBAAmB,CAAC,CAAC;YAClD,MAAM,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC,IAAI,CAAC,eAAe,CAAC,CAAC;YAC5C,MAAM,CAAC,IAAI,CAAC,YAAY,CAAC,CAAC,OAAO,CAAC,CAAC,YAAY,EAAE,OAAO,CAAC,CAAC,CAAC;YAC3D,MAAM,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC,OAAO,CAAC;gBAC7B,sBAAsB,EAAE,OAAO;gBAC/B,sBAAsB,EAAE,MAAM;gBAC9B,wBAAwB,EAAE,IAAI;aAC/B,CAAC,CAAC;YACH,MAAM,CAAC,IAAI,CAAC,OAAO,CAAC,aAAa,CAAC,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;QAClD,CAAC,CAAC,CAAC;QACH,yEAAyE;QACzE,MAAM,CAAC,IAAI,CAAC,CAAC,oBAAoB,CAAC,EAAE,KAAK,EAAE,aAAa,EAAE,CAAC,CAAC;IAC9D,CAAC,CAAC,CAAC;IAEH,IAAI,CAAC,wCAAwC,EAAE,KAAK,IAAI,EAAE;QACxD,MAAM,MAAM,GAAG,UAAU,CAAC,EAAE,IAAI,EAAE,IAAI,CAAC,EAAE,EAAE,CAAC,iBAAiB,CAAC,kCAAmB,CAAC,EAAE,CAAC,CAAC;QACtF,MAAM,CAAC,MAAM,IAAA,iBAAS,EAAC,MAAM,EAAE,eAAe,CAAC,CAAC,CAAC,mBAAmB,CAAC,CAAC,IAAI,EAAE,EAAE;YAC5E,MAAM,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC,aAAa,EAAE,CAAC;YACvC,MAAM,CAAC,IAAI,CAAC,YAAY,CAAC,CAAC,aAAa,EAAE,CAAC;YAC1C,MAAM,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC,aAAa,EAAE,CAAC;YACvC,MAAM,CAAC,IAAI,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;QAC5C,CAAC,CAAC,CAAC;IACL,CAAC,CAAC,CAAC;IAEH,IAAI,CAAC,sDAAsD,EAAE,KAAK,IAAI,EAAE;QACtE,MAAM,IAAI,GAAG,IAAI,CAAC,EAAE,EAAE,CAAC,iBAAiB,CAAC,2BAAY,CAAC,CAAC;QACvD,MAAM,MAAM,GAAG,UAAU,CAAC,EAAE,IAAI,EAAE,CAAC,CAAC;QACpC,MAAM,CAAC,MAAM,IAAA,iBAAS,EAAC,MAAM,EAAE,aAAa,EAAE,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC,SAAS,EAAE,CAAC;QAC9E,MAAM,CAAC,IAAI,CAAC,CAAC,oBAAoB,CAAC,EAAE,KAAK,EAAE,aAAa,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC,CAAC;IAC7E,CAAC,CAAC,CAAC;IAEH,IAAI,CAAC,8CAA8C,EAAE,KAAK,IAAI,EAAE;QAC9D,MAAM,MAAM,GAAG,UAAU,CAAC,EAAE,IAAI,EAAE,IAAI,CAAC,EAAE,EAAE,CAAC,iBAAiB,CAAC,IAAI,KAAK,CAAC,iBAAiB,CAAC,CAAC,EAAE,CAAC,CAAC;QAC/F,MAAM,CAAC,MAAM,IAAA,iBAAS,EAAC,MAAM,EAAE,aAAa,CAAC,CAAC,CAAC,UAAU,CAAC,iBAAiB,CAAC,CAAC;IAC/E,CAAC,CAAC,CAAC;AACL,CAAC,CAAC,CAAC;AAEH,QAAQ,CAAC,aAAa,EAAE,GAAG,EAAE;IAC3B,IAAI,CAAC,+DAA+D,EAAE,KAAK,IAAI,EAAE;QAC/E,MAAM,GAAG,GAAG,IAAI,CAAC,EAAE,EAAE,CAAC,iBAAiB,CAAC,EAAE,MAAM,EAAE,SAAS,EAAE,CAAC,CAAC;QAC/D,MAAM,MAAM,GAAG,UAAU,CAAC,EAAE,MAAM,EAAE,GAAG,EAAE,CAAC,CAAC;QAC3C,MAAM,CAAC,MAAM,IAAA,mBAAW,EAAC,MAAM,EAAE,aAAa,CAAC,CAAC,CAAC,aAAa,CAAC;YAC7D,KAAK,EAAE,aAAa;YACpB,OAAO,EAAE,IAAI;SACd,CAAC,CAAC;QACH,MAAM,CAAC,GAAG,CAAC,CAAC,oBAAoB,CAAC,EAAE,KAAK,EAAE,aAAa,EAAE,CAAC,CAAC;IAC7D,CAAC,CAAC,CAAC;IAEH,IAAI,CAAC,8CAA8C,EAAE,KAAK,IAAI,EAAE;QAC9D,MAAM,MAAM,GAAG,UAAU,CAAC,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,EAAE,CAAC,iBAAiB,CAAC,IAAI,KAAK,CAAC,iBAAiB,CAAC,CAAC,EAAE,CAAC,CAAC;QACjG,MAAM,CAAC,MAAM,IAAA,mBAAW,EAAC,MAAM,EAAE,aAAa,CAAC,CAAC,CAAC,UAAU,CAAC,iBAAiB,CAAC,CAAC;IACjF,CAAC,CAAC,CAAC;AACL,CAAC,CAAC,CAAC","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"]}
|